Design System

Table of Contents


Overview

Dual Distribution Model — npm package for types/tokens + MF remote for components Design System — Dual Distribution Model packages/design-system/ Components · Tokens · Hooks · Utils npm Package (Build-time) TypeScript types (.d.ts) Design tokens · CSS variables Utility functions · Shared hooks tsup → dist/ tree-shakeable MF Remote (Runtime) Compiled React components Loaded by Shell via CDN Zero-rebuild updates Rsbuild → dist-mf/ mf-manifest.json

The design system provides a shared component library, design tokens, hooks, and utilities to every micro frontend (MFE) on the platform. It follows a dual distribution model to satisfy two fundamentally different consumption needs:

Distribution ChannelWhat It DeliversWhen It Is Used
npm packageTypeScript types, design tokens, CSS variable definitions, utility functions, shared hooksBuild-time -- consumed by bundlers and type checkers during development and CI
Module Federation remoteCompiled, runtime-ready React componentsRuntime -- loaded by the shell or MFEs in the browser, no rebuild required

Why dual distribution?

A single distribution channel cannot serve both needs efficiently:

  • npm alone would force every MFE to bundle its own copy of the design system components. Any design system update would require rebuilding and redeploying every MFE that depends on it. However, npm is the natural vehicle for build-time artifacts -- TypeScript type declarations, token constants consumed by IDE IntelliSense, and utility functions that tree-shake down to only what each MFE imports.

  • Module Federation alone cannot expose TypeScript types at build time. Editors and CI type-checking depend on .d.ts files that must be present on disk before the application runs. MF remotes are resolved at runtime in the browser, which is too late for static analysis.

The dual model gives teams the best of both worlds: full type safety and token access at build time via the npm package, and zero-rebuild runtime component updates via the Module Federation remote. When the design system ships a patch (for example, adjusting a button's border radius), every MFE picks up the change on its next page load without any redeployment.

The design system source lives in the monorepo under packages/design-system/ and is maintained by the platform team alongside the shell application and shared infrastructure packages.


Package Structure

Design System Package Structure Package Structure — packages/design-system/ src/components/ Button/ Modal/ ThemeProvider/ Typography/ .tsx + .module.css + .test.tsx src/tokens/ colors.ts spacing.ts typography.ts breakpoints.ts shadows.ts src/hooks/ useMediaQuery.ts useTheme.ts Shared React hooks for all MFEs Build Outputs dist/ (npm, tsup) dist-mf/ (MF remote) mf-manifest.json @mf-types.zip
packages/design-system/
├── src/
│   ├── components/                # React UI components
│   │   ├── Button/
│   │   │   ├── Button.tsx         # Component implementation
│   │   │   ├── Button.module.css  # Scoped styles via CSS Modules
│   │   │   ├── Button.test.tsx    # Unit + interaction tests
│   │   │   └── index.ts          # Public barrel export
│   │   ├── Modal/
│   │   │   ├── Modal.tsx
│   │   │   ├── Modal.module.css
│   │   │   ├── Modal.test.tsx
│   │   │   └── index.ts
│   │   ├── ThemeProvider/
│   │   │   ├── ThemeProvider.tsx
│   │   │   ├── ThemeProvider.test.tsx
│   │   │   └── index.ts
│   │   ├── Typography/
│   │   │   ├── Typography.tsx
│   │   │   ├── Typography.module.css
│   │   │   ├── Typography.test.tsx
│   │   │   └── index.ts
│   │   └── index.ts              # Re-exports all components
│   ├── tokens/                    # Design tokens (colors, spacing, typography)
│   │   ├── colors.ts
│   │   ├── spacing.ts
│   │   ├── typography.ts
│   │   ├── breakpoints.ts
│   │   ├── shadows.ts
│   │   └── index.ts
│   ├── hooks/                     # Shared React hooks
│   │   ├── useMediaQuery.ts
│   │   ├── useTheme.ts
│   │   ├── useClickOutside.ts
│   │   └── index.ts
│   ├── utils/                     # Utility functions
│   │   ├── cn.ts                  # Class name merge utility
│   │   ├── colorContrast.ts       # WCAG contrast calculations
│   │   ├── responsive.ts          # Responsive helpers
│   │   └── index.ts
│   ├── types/                     # Shared TypeScript types
│   │   ├── theme.ts
│   │   ├── components.ts
│   │   └── index.ts
│   └── index.ts                   # Main entry point
├── tsup.config.ts                 # Build config for npm distribution
├── rsbuild.config.ts              # Build config for MF remote
├── package.json
├── tsconfig.json
├── vitest.config.ts
└── CHANGELOG.md

Each component follows a consistent internal structure:

  • Component.tsx -- the React implementation, accepting typed props and using CSS Modules for styling.
  • Component.module.css -- scoped CSS that compiles to unique class names, preventing collisions across MFEs.
  • Component.test.tsx -- co-located tests using Vitest and Testing Library.
  • index.ts -- barrel file that re-exports the component and its prop types.

Build Configuration

npm Distribution (tsup)

tsup compiles the package into tree-shakeable ESM and CommonJS outputs with full TypeScript declarations. Multiple entry points allow consumers to import only what they need, keeping bundle sizes minimal.

Note: tsup is currently in maintenance mode. The maintainer has shifted focus to tsdown, an emerging alternative built on top of Rolldown. For new projects, consider evaluating tsdown as a replacement. For existing projects like this one, tsup remains stable and functional, but teams should be aware of the reduced maintenance cadence when planning long-term tooling decisions.

tsup.config.ts

import { defineConfig } from "tsup";

export default defineConfig({
  entry: {
    index: "src/index.ts",
    tokens: "src/tokens/index.ts",
    hooks: "src/hooks/index.ts",
    utils: "src/utils/index.ts",
    types: "src/types/index.ts",
  },
  format: ["esm", "cjs"],
  dts: true,
  sourcemap: true,
  clean: true,
  splitting: true,
  treeshake: true,
  external: ["react", "react-dom"],
  outDir: "dist",
  esbuildOptions(options) {
    options.banner = {
      js: '"use client";',
    };
  },
  onSuccess: "tsc --emitDeclarationOnly --declaration --declarationDir dist/types",
});

Key decisions:

  • Multiple entry points (index, tokens, hooks, utils, types) so consumers can deep-import sub-paths without pulling in the entire package.
  • external: ["react", "react-dom"] ensures React is not bundled -- the consuming application provides its own copy.
  • splitting: true enables code splitting within the ESM output so shared internal modules are not duplicated.
  • dts: true generates .d.ts files alongside every output, giving consumers full IntelliSense.

package.json exports map

The exports field uses Node.js conditional exports to direct bundlers and runtimes to the correct format:

{
  "name": "@org/design-system",
  "version": "2.4.0",
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "sideEffects": false,
  "exports": {
    ".": {
      "import": {
        "types": "./dist/index.d.ts",
        "default": "./dist/index.js"
      },
      "require": {
        "types": "./dist/index.d.cts",
        "default": "./dist/index.cjs"
      }
    },
    "./tokens": {
      "import": {
        "types": "./dist/tokens.d.ts",
        "default": "./dist/tokens.js"
      },
      "require": {
        "types": "./dist/tokens.d.cts",
        "default": "./dist/tokens.cjs"
      }
    },
    "./hooks": {
      "import": {
        "types": "./dist/hooks.d.ts",
        "default": "./dist/hooks.js"
      },
      "require": {
        "types": "./dist/hooks.d.cts",
        "default": "./dist/hooks.cjs"
      }
    },
    "./utils": {
      "import": {
        "types": "./dist/utils.d.ts",
        "default": "./dist/utils.js"
      },
      "require": {
        "types": "./dist/utils.d.cts",
        "default": "./dist/utils.cjs"
      }
    },
    "./types": {
      "import": {
        "types": "./dist/types.d.ts",
        "default": "./dist/types.js"
      },
      "require": {
        "types": "./dist/types.d.cts",
        "default": "./dist/types.cjs"
      }
    },
    "./styles.css": "./dist/styles.css"
  },
  "files": [
    "dist",
    "CHANGELOG.md"
  ],
  "scripts": {
    "build:npm": "tsup",
    "build:mf": "rsbuild build",
    "build": "pnpm build:npm && pnpm build:mf",
    "dev": "rsbuild dev",
    "test": "vitest run",
    "test:watch": "vitest",
    "lint": "eslint src/",
    "typecheck": "tsc --noEmit",
    "storybook": "storybook dev -p 6006",
    "build-storybook": "storybook build"
  },
  "peerDependencies": {
    "react": "^19.2.4",
    "react-dom": "^19.2.4"
  },
  "devDependencies": {
    "@rsbuild/core": "^1.7.3",
    "@module-federation/rsbuild-plugin": "^2.0.1",
    "@module-federation/enhanced": "^2.0.1",
    "@storybook/react-vite": "^10.2.10",
    "tsup": "^8.3.0",
    "typescript": "^5.9.3",
    "vitest": "^4.0.18",
    "@testing-library/react": "^16.1.0"
  }
}

The "sideEffects": false declaration tells bundlers that any unused export can be safely tree-shaken away. The separate "./styles.css" export gives consumers an explicit opt-in path for the base stylesheet if they need it.

Important: CSS Modules and sideEffects. While "sideEffects": false is correct for pure JavaScript and TypeScript exports, CSS Modules are side effects -- importing a .module.css file causes styles to be injected into the document, and bundlers may incorrectly tree-shake them away if they are not explicitly listed. If the package includes CSS output that consumers import directly, update the field to declare CSS files as side effects:

"sideEffects": ["**/*.css"]

This ensures bundlers preserve CSS imports during tree-shaking while still eliminating unused JavaScript exports.

Module Federation Remote

The Rsbuild configuration exposes runtime-ready React components via Module Federation v2. These are loaded by the shell application and MFEs at runtime -- no npm install or rebuild required on the consumer side.

rsbuild.config.ts

import { defineConfig } from "@rsbuild/core";
import { pluginReact } from "@rsbuild/plugin-react";
import { pluginModuleFederation } from "@module-federation/rsbuild-plugin";

export default defineConfig({
  plugins: [
    pluginReact(),
    pluginModuleFederation({
      name: "design_system",
      filename: "remoteEntry.js",
      exposes: {
        "./Button": "./src/components/Button/index.ts",
        "./Modal": "./src/components/Modal/index.ts",
        "./Typography": "./src/components/Typography/index.ts",
        "./ThemeProvider": "./src/components/ThemeProvider/index.ts",
        "./hooks": "./src/hooks/index.ts",
        "./utils": "./src/utils/index.ts",
      },
      shared: {
        react: {
          singleton: true,
          requiredVersion: "^19.2.4",
          eager: false,
        },
        "react-dom": {
          singleton: true,
          requiredVersion: "^19.2.4",
          eager: false,
        },
      },
      manifest: {
        filePath: "mf-manifest.json",
      },
    }),
  ],

  output: {
    assetPrefix: "auto",
    cleanDistPath: true,
    distPath: {
      root: "dist-mf",
    },
    filename: {
      js: "[name].[contenthash:8].js",
      css: "[name].[contenthash:8].css",
    },
  },

  server: {
    port: 3010,
    headers: {
      "Access-Control-Allow-Origin": "*",
    },
  },

  performance: {
    chunkSplit: {
      strategy: "split-by-experience",
    },
  },
});

Key decisions:

  • singleton: true for React and ReactDOM ensures every MFE shares exactly one React instance. Without this, hooks break due to multiple React copies.
  • manifest: { filePath: "mf-manifest.json" } generates a manifest file that the shell reads at runtime to discover available modules and their asset URLs. This manifest is deployed to the CDN alongside the compiled assets.
  • Separate output directory (dist-mf) keeps the MF build artifacts separate from the npm distribution in dist/, avoiding any interference between the two pipelines.
  • Content-hash filenames enable aggressive CDN caching -- assets are immutable and cache-busted on every change.
  • assetPrefix: "auto" allows the runtime to resolve asset URLs relative to the remoteEntry.js location, which is critical when assets are served from a CDN with a different origin than the host application.

Versioning with Changesets

The monorepo uses Changesets for independent semantic versioning of each package. Every package in the workspace can be versioned and released independently, so a patch to the design system does not force a version bump on unrelated packages.

Changesets Versioning Flow — changeset, version, publish, Renovate Changesets Versioning Flow changesetDev adds .md Version PRCI creates PR PublishGitHub Packages RenovateAuto-PR to MFEs MFE UpdatedTeam merges Renovate PR Independent versioning — each package bumped separately · CHANGELOG generated automatically Polyrepo MFEs consume updates via Renovate PRs (no manual version coordination)

Configuration

.changeset/config.json

{
  "$schema": "https://unpkg.com/@changesets/config@3.0.4/schema.json",
  "changelog": [
    "@changesets/changelog-github",
    { "repo": "org/micro-frontends-platform" }
  ],
  "commit": false,
  "fixed": [],
  "linked": [],
  "access": "restricted",
  "baseBranch": "main",
  "updateInternalDependencies": "patch",
  "ignore": [],
  "privatePackages": {
    "version": false,
    "tag": false
  }
}

Configuration notes:

  • "access": "restricted" because the packages are published to GitHub Packages under the organization scope, not the public npm registry.
  • "updateInternalDependencies": "patch" ensures that when a shared package like the design system gets a new version, any monorepo package that depends on it has its dependency range updated automatically.
  • "changelog": ["@changesets/changelog-github", ...] generates changelogs that link back to the PR and commit on GitHub, making it easy to trace why a change was made.
  • "privatePackages": { "version": false, "tag": false } prevents private packages (like the shell app, which is deployed but never published to npm) from being versioned or tagged by Changesets.

Workflow

The release workflow has four stages:

  1. Developer adds a changeset. When a PR modifies the design system, the developer runs pnpm changeset and selects the affected package(s), the semver bump type (patch, minor, major), and writes a human-readable summary.

  2. Changeset file is committed with the PR. The changeset is a small Markdown file in .changeset/ that records the intent:

.changeset/blue-dogs-dance.md (example)

---
"@org/design-system": minor
---

Add new `Tooltip` component with configurable placement and delay. Supports both hover
and focus triggers for accessibility compliance.
  1. Changesets GitHub Action creates a release PR. After the feature PR merges to main, the Changesets bot aggregates all pending changesets into a single "Version Packages" PR. This PR updates package.json versions, writes CHANGELOG.md entries, and removes the consumed changeset files.

  2. Merging the release PR publishes to npm. When the team merges the "Version Packages" PR, a CI workflow runs changeset publish, which publishes the new version to GitHub Packages and creates a Git tag.

How consuming MFE teams know when to update

For MFEs in external polyrepos that depend on @org/design-system as an npm package (for types and tokens), Renovate Bot is configured to monitor GitHub Packages and automatically create PRs when a new version is published. Teams review the auto-generated PR, verify their types still compile, and merge.

For the Module Federation remote, MFEs do not need to do anything. The shell loads the latest design system remote at runtime based on the MF manifest, so runtime components are updated automatically on every deployment of the design system.


Publishing to GitHub Packages

All packages in the monorepo are published to GitHub Packages under the organization scope @org.

Registry configuration

Monorepo root .npmrc

@org:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}
always-auth=true

Consuming polyrepo .npmrc

@org:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}

The GITHUB_TOKEN is injected as an environment variable -- in CI it comes from the GitHub Actions secrets.GITHUB_TOKEN or a fine-grained PAT, and locally developers use a PAT stored in their shell profile or a credential manager.

CI/CD publishing workflow

.github/workflows/release.yml

name: Release Packages

on:
  push:
    branches:
      - main

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: false

permissions:
  contents: write
  packages: write
  pull-requests: write

jobs:
  release:
    name: Version & Publish
    runs-on: ubuntu-latest
    timeout-minutes: 15

    steps:
      - name: Checkout repository
        uses: actions/checkout@v6
        with:
          fetch-depth: 0

      - name: Setup pnpm
        uses: pnpm/action-setup@v4

      - name: Setup Node.js
        uses: actions/setup-node@v6
        with:
          node-version: 22
          cache: "pnpm"
          registry-url: "https://npm.pkg.github.com"

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Build all packages
        run: pnpm turbo run build --filter="./packages/*"

      - name: Run tests
        run: pnpm turbo run test --filter="./packages/*"

      - name: Create release PR or publish
        id: changesets
        uses: changesets/action@v1
        with:
          version: pnpm changeset version
          publish: pnpm changeset publish
          title: "chore: version packages"
          commit: "chore: version packages"
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NPM_TOKEN: ${{ secrets.GITHUB_TOKEN }}

      - name: Deploy design system MF remote
        if: steps.changesets.outputs.published == 'true'
        run: |
          # Check if design-system was among the published packages
          PUBLISHED=$(echo '${{ steps.changesets.outputs.publishedPackages }}' | jq -r '.[] | select(.name == "@org/design-system") | .name')
          if [ -n "$PUBLISHED" ]; then
            echo "Design system was published, deploying MF remote..."
            pnpm --filter @org/design-system build:mf
            pnpm turbo run deploy:mf --filter=@org/design-system
          fi

The workflow handles two scenarios on each push to main:

  • If there are pending changesets: The action creates (or updates) a "Version Packages" PR that bumps versions and updates changelogs. No packages are published yet.
  • If there are no pending changesets (i.e., the "Version Packages" PR was just merged): The action runs changeset publish, which publishes every package that has a new version to GitHub Packages and creates Git tags.

The final step conditionally deploys the design system's Module Federation remote only when the design system itself was among the published packages.


Consuming the Design System

As npm Package (Build-time)

MFE teams install the design system as a dev dependency (since the runtime components come via Module Federation, the npm package is only needed during development and CI):

pnpm add -D @org/design-system

Importing types and component props:

import type { ButtonProps, ModalProps, TypographyProps } from "@org/design-system";
import type { Theme, ThemeMode } from "@org/design-system/types";

// Full IntelliSense and type-checking for component props
const buttonConfig: ButtonProps = {
  variant: "primary",
  size: "md",
  disabled: false,
  onClick: () => handleSubmit(),
};

Importing design tokens:

import { colors, spacing, typography, breakpoints } from "@org/design-system/tokens";

// Use tokens in styled-components, CSS-in-JS, or Tailwind config
const customStyles = {
  padding: spacing.md,         // "16px"
  color: colors.primary[600],  // "#2563EB"
  fontSize: typography.body.fontSize, // "1rem"
};

// Or use tokens to configure a Tailwind theme
// tailwind.config.ts
export default {
  theme: {
    extend: {
      colors: colors,
      spacing: spacing,
    },
  },
};

Importing shared hooks:

import { useMediaQuery, useClickOutside } from "@org/design-system/hooks";

function Sidebar() {
  const isMobile = useMediaQuery(`(max-width: ${breakpoints.md})`);
  const sidebarRef = useClickOutside<HTMLDivElement>(() => closeSidebar());

  return (
    <aside ref={sidebarRef} className={isMobile ? "overlay" : "docked"}>
      {/* ... */}
    </aside>
  );
}

Importing utility functions:

import { cn, meetsContrastRatio } from "@org/design-system/utils";

// cn() merges class names, handling conditional classes cleanly
<div className={cn("card", isActive && "card--active", className)} />

// Accessibility: check if custom theme colors meet WCAG AA
const isAccessible = meetsContrastRatio(foreground, background, "AA");

As Module Federation Remote (Runtime)

At runtime, the actual React components are loaded via Module Federation. The shell application registers the design system as a remote in its configuration:

Shell rsbuild.config.ts (relevant excerpt):

pluginModuleFederation({
  name: "shell",
  remotes: {
    design_system: "design_system@https://cdn.example.com/design-system/mf-manifest.json",
  },
  shared: {
    react: { singleton: true, requiredVersion: "^19.2.4" },
    "react-dom": { singleton: true, requiredVersion: "^19.2.4" },
  },
}),

Direct dynamic import in an MFE:

import { loadRemote } from "@module-federation/enhanced/runtime";
import type { ButtonProps } from "@org/design-system";

// loadRemote returns the module at runtime from the MF remote
const ButtonModule = await loadRemote<{ default: React.ComponentType<ButtonProps> }>(
  "design_system/Button"
);
const Button = ButtonModule.default;

Thin Wrapper Package Pattern

To provide a clean developer experience that feels like importing from a regular package, a thin wrapper package re-exports every design system component as a lazy-loaded React component. MFE developers never need to call loadRemote directly.

packages/design-system-remote/src/Button.tsx

import React, { lazy, Suspense } from "react";
import { loadRemote } from "@module-federation/enhanced/runtime";
import type { ButtonProps } from "@org/design-system";

const RemoteButton = lazy(async () => {
  const module = await loadRemote<{ default: React.ComponentType<ButtonProps> }>(
    "design_system/Button"
  );
  if (!module) {
    throw new Error("Failed to load design_system/Button remote module");
  }
  return { default: module.default };
});

/**
 * Button component loaded at runtime from the design system MF remote.
 * Accepts the same props as the design system Button.
 * Renders a skeleton placeholder while the remote chunk is loading.
 */
export function Button(props: ButtonProps) {
  return (
    <Suspense fallback={<ButtonSkeleton size={props.size} />}>
      <RemoteButton {...props} />
    </Suspense>
  );
}

function ButtonSkeleton({ size = "md" }: { size?: ButtonProps["size"] }) {
  const heightMap = { sm: "32px", md: "40px", lg: "48px" } as const;
  return (
    <div
      role="presentation"
      aria-hidden="true"
      style={{
        height: heightMap[size ?? "md"],
        width: "120px",
        borderRadius: "6px",
        backgroundColor: "var(--ds-color-neutral-100)",
        animation: "pulse 1.5s ease-in-out infinite",
      }}
    />
  );
}

export type { ButtonProps };

Usage in an MFE (feels like a regular import):

import { Button } from "@org/design-system-remote";
import { Modal } from "@org/design-system-remote";

function CheckoutPage() {
  return (
    <div>
      <h1>Checkout</h1>
      <Button variant="primary" size="lg" onClick={handlePay}>
        Complete Purchase
      </Button>
      <Modal open={showTerms} onClose={() => setShowTerms(false)}>
        <p>Terms and conditions...</p>
      </Modal>
    </div>
  );
}

This pattern gives MFE developers:

  • Type safety -- props are checked at build time via the @org/design-system types.
  • Automatic code splitting -- components are loaded on demand.
  • Graceful loading states -- each wrapper includes a purpose-built skeleton fallback.
  • Decoupled deployments -- the design system team can ship visual updates without MFE teams rebuilding.

Theming and CSS Strategy

Design Token Structure

Design tokens are the single source of truth for visual decisions. They are defined as TypeScript constants and compiled into CSS custom properties at build time.

src/tokens/colors.ts

export const colors = {
  primary: {
    50: "#EFF6FF",
    100: "#DBEAFE",
    200: "#BFDBFE",
    300: "#93C5FD",
    400: "#60A5FA",
    500: "#3B82F6",
    600: "#2563EB",
    700: "#1D4ED8",
    800: "#1E40AF",
    900: "#1E3A8A",
    950: "#172554",
  },
  neutral: {
    50: "#F9FAFB",
    100: "#F3F4F6",
    200: "#E5E7EB",
    300: "#D1D5DB",
    400: "#9CA3AF",
    500: "#6B7280",
    600: "#4B5563",
    700: "#374151",
    800: "#1F2937",
    900: "#111827",
    950: "#030712",
  },
  success: {
    500: "#22C55E",
    600: "#16A34A",
    700: "#15803D",
  },
  warning: {
    500: "#F59E0B",
    600: "#D97706",
    700: "#B45309",
  },
  error: {
    500: "#EF4444",
    600: "#DC2626",
    700: "#B91C1C",
  },
} as const;

export type Colors = typeof colors;

src/tokens/spacing.ts

export const spacing = {
  px: "1px",
  0: "0",
  0.5: "2px",
  1: "4px",
  1.5: "6px",
  2: "8px",
  2.5: "10px",
  3: "12px",
  4: "16px",
  5: "20px",
  6: "24px",
  8: "32px",
  10: "40px",
  12: "48px",
  16: "64px",
  20: "80px",
  24: "96px",
} as const;

// Semantic aliases for common use cases
export const space = {
  xs: spacing[1],   // 4px
  sm: spacing[2],   // 8px
  md: spacing[4],   // 16px
  lg: spacing[6],   // 24px
  xl: spacing[8],   // 32px
  "2xl": spacing[12], // 48px
} as const;

export type Spacing = typeof spacing;
export type Space = typeof space;

CSS Custom Properties Generation

Tokens are compiled into CSS custom properties so that components can reference them in CSS Modules without importing JavaScript:

src/tokens/css-variables.ts (build-time utility)

import { colors } from "./colors";
import { spacing, space } from "./spacing";
import { typography } from "./typography";

type NestedRecord = { [key: string]: string | NestedRecord };

function flattenTokens(obj: NestedRecord, prefix: string): Record<string, string> {
  const result: Record<string, string> = {};

  for (const [key, value] of Object.entries(obj)) {
    const varName = `${prefix}-${key}`;
    if (typeof value === "string") {
      result[varName] = value;
    } else {
      Object.assign(result, flattenTokens(value, varName));
    }
  }

  return result;
}

export function generateCSSVariables(): string {
  const allTokens: Record<string, string> = {
    ...flattenTokens(colors as unknown as NestedRecord, "--ds-color"),
    ...flattenTokens(spacing as unknown as NestedRecord, "--ds-spacing"),
    ...flattenTokens(space as unknown as NestedRecord, "--ds-space"),
    ...flattenTokens(typography as unknown as NestedRecord, "--ds-typography"),
  };

  const declarations = Object.entries(allTokens)
    .map(([name, value]) => `  ${name}: ${value};`)
    .join("\n");

  return `:root {\n${declarations}\n}`;
}

This generates CSS like:

:root {
  --ds-color-primary-50: #EFF6FF;
  --ds-color-primary-100: #DBEAFE;
  /* ... */
  --ds-space-xs: 4px;
  --ds-space-sm: 8px;
  --ds-space-md: 16px;
  /* ... */
}

ThemeProvider Component

ThemeProvider Context Flow — Shell to ThemeProvider to MFE components ThemeProvider Context Flow Shell App <ThemeProvider> Manages theme state CSS custom properties Light / Dark mode context ThemeContext useTheme() hook { theme, setTheme } MFE A: Button MFE B: Modal MFE C: Card CSS Custom Properties --color-primary: #2764F4 --color-background: #FBFCFD --spacing-md: 16px Applied to :root element

The ThemeProvider manages theme state and applies CSS custom properties to a root element. It supports both system preference detection and manual overrides.

src/components/ThemeProvider/ThemeProvider.tsx

import React, {
  createContext,
  useCallback,
  useEffect,
  useMemo,
  useState,
} from "react";
import type { ReactNode } from "react";

export type ThemeMode = "light" | "dark" | "system";

interface ThemeContextValue {
  /** The user's preference: "light", "dark", or "system". */
  mode: ThemeMode;
  /** The resolved mode after evaluating system preference. Always "light" or "dark". */
  resolvedMode: "light" | "dark";
  /** Update the theme mode. */
  setMode: (mode: ThemeMode) => void;
}

export const ThemeContext = createContext<ThemeContextValue | null>(null);

const STORAGE_KEY = "ds-theme-mode";

// Semantic token overrides per theme
const lightTokens: Record<string, string> = {
  "--ds-bg-primary": "var(--ds-color-neutral-50)",
  "--ds-bg-secondary": "var(--ds-color-neutral-100)",
  "--ds-bg-surface": "#FFFFFF",
  "--ds-text-primary": "var(--ds-color-neutral-900)",
  "--ds-text-secondary": "var(--ds-color-neutral-600)",
  "--ds-text-muted": "var(--ds-color-neutral-400)",
  "--ds-border-default": "var(--ds-color-neutral-200)",
  "--ds-border-strong": "var(--ds-color-neutral-300)",
  "--ds-shadow-sm": "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
  "--ds-shadow-md": "0 4px 6px -1px rgba(0, 0, 0, 0.1)",
};

const darkTokens: Record<string, string> = {
  "--ds-bg-primary": "var(--ds-color-neutral-900)",
  "--ds-bg-secondary": "var(--ds-color-neutral-800)",
  "--ds-bg-surface": "var(--ds-color-neutral-950)",
  "--ds-text-primary": "var(--ds-color-neutral-50)",
  "--ds-text-secondary": "var(--ds-color-neutral-300)",
  "--ds-text-muted": "var(--ds-color-neutral-500)",
  "--ds-border-default": "var(--ds-color-neutral-700)",
  "--ds-border-strong": "var(--ds-color-neutral-600)",
  "--ds-shadow-sm": "0 1px 2px 0 rgba(0, 0, 0, 0.3)",
  "--ds-shadow-md": "0 4px 6px -1px rgba(0, 0, 0, 0.4)",
};

function getSystemPreference(): "light" | "dark" {
  if (typeof window === "undefined") return "light";
  return window.matchMedia("(prefers-color-scheme: dark)").matches
    ? "dark"
    : "light";
}

function resolveMode(mode: ThemeMode): "light" | "dark" {
  return mode === "system" ? getSystemPreference() : mode;
}

interface ThemeProviderProps {
  children: ReactNode;
  /** Initial theme mode. Defaults to "system". */
  defaultMode?: ThemeMode;
  /** CSS selector for the element that receives theme CSS variables. Defaults to ":root". */
  targetSelector?: string;
}

export function ThemeProvider({
  children,
  defaultMode = "system",
  targetSelector = ":root",
}: ThemeProviderProps) {
  const [mode, setModeState] = useState<ThemeMode>(() => {
    if (typeof window === "undefined") return defaultMode;
    return (localStorage.getItem(STORAGE_KEY) as ThemeMode) ?? defaultMode;
  });

  const [resolvedMode, setResolvedMode] = useState<"light" | "dark">(() =>
    resolveMode(mode)
  );

  // Apply CSS variables to the target element
  const applyTheme = useCallback(
    (resolved: "light" | "dark") => {
      const target =
        targetSelector === ":root"
          ? document.documentElement
          : document.querySelector(targetSelector);

      if (!target || !(target instanceof HTMLElement)) return;

      const tokens = resolved === "dark" ? darkTokens : lightTokens;
      for (const [prop, value] of Object.entries(tokens)) {
        target.style.setProperty(prop, value);
      }

      target.setAttribute("data-theme", resolved);
    },
    [targetSelector]
  );

  // Listen for system preference changes when mode is "system"
  useEffect(() => {
    const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");

    function handleChange() {
      if (mode === "system") {
        const newResolved = getSystemPreference();
        setResolvedMode(newResolved);
        applyTheme(newResolved);
      }
    }

    mediaQuery.addEventListener("change", handleChange);
    return () => mediaQuery.removeEventListener("change", handleChange);
  }, [mode, applyTheme]);

  // Apply theme whenever the resolved mode changes
  useEffect(() => {
    const resolved = resolveMode(mode);
    setResolvedMode(resolved);
    applyTheme(resolved);
  }, [mode, applyTheme]);

  const setMode = useCallback((newMode: ThemeMode) => {
    setModeState(newMode);
    localStorage.setItem(STORAGE_KEY, newMode);
  }, []);

  const contextValue = useMemo<ThemeContextValue>(
    () => ({ mode, resolvedMode, setMode }),
    [mode, resolvedMode, setMode]
  );

  return (
    <ThemeContext.Provider value={contextValue}>
      {children}
    </ThemeContext.Provider>
  );
}

src/hooks/useTheme.ts

import { useContext } from "react";
import { ThemeContext } from "../components/ThemeProvider/ThemeProvider";
import type { ThemeMode } from "../components/ThemeProvider/ThemeProvider";

export function useTheme() {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error("useTheme must be used within a <ThemeProvider>");
  }
  return context;
}

export type { ThemeMode };

CSS Modules for Component Scoping

Every component uses CSS Modules to avoid style collisions across MFEs. Class names are hashed at build time, ensuring that a .button class in the design system can never conflict with a .button class in an MFE.

src/components/Button/Button.module.css

.button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--ds-space-xs);
  border: 1px solid transparent;
  border-radius: 6px;
  font-family: var(--ds-typography-fontFamily);
  font-weight: 500;
  cursor: pointer;
  transition: background-color 150ms ease, border-color 150ms ease,
    box-shadow 150ms ease;
}

.button:focus-visible {
  outline: 2px solid var(--ds-color-primary-500);
  outline-offset: 2px;
}

.button:disabled {
  cursor: not-allowed;
  opacity: 0.5;
}

/* Size variants */
.sm {
  height: 32px;
  padding: 0 var(--ds-space-sm);
  font-size: 0.875rem;
}

.md {
  height: 40px;
  padding: 0 var(--ds-space-md);
  font-size: 0.875rem;
}

.lg {
  height: 48px;
  padding: 0 var(--ds-space-lg);
  font-size: 1rem;
}

/* Visual variants */
.primary {
  background-color: var(--ds-color-primary-600);
  color: white;
}

.primary:hover:not(:disabled) {
  background-color: var(--ds-color-primary-700);
}

.secondary {
  background-color: var(--ds-bg-surface);
  color: var(--ds-text-primary);
  border-color: var(--ds-border-default);
}

.secondary:hover:not(:disabled) {
  background-color: var(--ds-bg-secondary);
  border-color: var(--ds-border-strong);
}

.ghost {
  background-color: transparent;
  color: var(--ds-text-primary);
}

.ghost:hover:not(:disabled) {
  background-color: var(--ds-bg-secondary);
}

src/components/Button/Button.tsx

import React, { forwardRef } from "react";
import { cn } from "../../utils/cn";
import styles from "./Button.module.css";

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  /** Visual variant of the button. */
  variant?: "primary" | "secondary" | "ghost" | "danger";
  /** Size of the button. */
  size?: "sm" | "md" | "lg";
  /** Show a loading spinner and disable interaction. */
  loading?: boolean;
  /** Icon to render before the label. */
  leftIcon?: React.ReactNode;
  /** Icon to render after the label. */
  rightIcon?: React.ReactNode;
}

export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  (
    {
      variant = "primary",
      size = "md",
      loading = false,
      leftIcon,
      rightIcon,
      disabled,
      className,
      children,
      ...rest
    },
    ref
  ) => {
    return (
      <button
        ref={ref}
        className={cn(
          styles.button,
          styles[variant],
          styles[size],
          loading && styles.loading,
          className
        )}
        disabled={disabled || loading}
        aria-busy={loading || undefined}
        {...rest}
      >
        {loading ? (
          <Spinner size={size} />
        ) : (
          <>
            {leftIcon && <span className={styles.icon}>{leftIcon}</span>}
            {children}
            {rightIcon && <span className={styles.icon}>{rightIcon}</span>}
          </>
        )}
      </button>
    );
  }
);

Button.displayName = "Button";

function Spinner({ size }: { size: "sm" | "md" | "lg" }) {
  const sizeMap = { sm: 14, md: 16, lg: 20 };
  const pixels = sizeMap[size];
  return (
    <svg
      className={styles.spinner}
      width={pixels}
      height={pixels}
      viewBox="0 0 24 24"
      fill="none"
      aria-hidden="true"
    >
      <circle
        cx="12"
        cy="12"
        r="10"
        stroke="currentColor"
        strokeWidth="3"
        strokeLinecap="round"
        strokeDasharray="60"
        strokeDashoffset="20"
      />
    </svg>
  );
}

Dark Mode Strategy

Dark mode is handled through two complementary mechanisms:

  1. System preference detection -- The ThemeProvider listens to prefers-color-scheme via matchMedia and automatically resolves to the user's OS preference when the mode is set to "system".

  2. Manual toggle -- Users can override the system preference with an explicit "light" or "dark" choice, which is persisted to localStorage and survives page reloads.

All semantic tokens (backgrounds, text colors, borders, shadows) are defined as CSS custom properties that change based on the active theme. Components reference these semantic tokens rather than raw color values, so dark mode support is automatic for every component.

/* Components reference semantic tokens, not raw colors */
.card {
  background-color: var(--ds-bg-surface);   /* white in light, neutral-950 in dark */
  color: var(--ds-text-primary);            /* neutral-900 in light, neutral-50 in dark */
  border: 1px solid var(--ds-border-default);
  box-shadow: var(--ds-shadow-sm);
}

Storybook

Storybook 10 serves as the interactive documentation, visual testing environment, and component playground for the design system.

Configuration

.storybook/main.ts

import type { StorybookConfig } from "@storybook/react-vite";

const config: StorybookConfig = {
  stories: ["../src/**/*.stories.@(ts|tsx)"],
  addons: [
    "@storybook/addon-essentials",
    "@storybook/addon-a11y",
    "@storybook/addon-interactions",
    "@storybook/addon-themes",
  ],
  framework: {
    name: "@storybook/react-vite",
    options: {},
  },
  typescript: {
    reactDocgen: "react-docgen-typescript",
  },
  viteFinal(config) {
    return config;
  },
};

export default config;

.storybook/preview.ts

import type { Preview } from "@storybook/react";
import { withThemeByDataAttribute } from "@storybook/addon-themes";
import "../src/styles/globals.css";

const preview: Preview = {
  parameters: {
    controls: {
      matchers: {
        color: /(background|color)$/i,
        date: /Date$/i,
      },
    },
    layout: "centered",
  },
  decorators: [
    withThemeByDataAttribute({
      themes: {
        light: "light",
        dark: "dark",
      },
      defaultTheme: "light",
      attributeName: "data-theme",
    }),
  ],
};

export default preview;

Example Story

Stories are organized by component, with each story file demonstrating all variants, states, and interaction patterns.

src/components/Button/Button.stories.tsx

import type { Meta, StoryObj } from "@storybook/react";
import { expect, userEvent, within } from "@storybook/test";
import { Button } from "./Button";

const meta: Meta<typeof Button> = {
  title: "Components/Button",
  component: Button,
  tags: ["autodocs"],
  argTypes: {
    variant: {
      control: "select",
      options: ["primary", "secondary", "ghost", "danger"],
      description: "The visual style of the button",
    },
    size: {
      control: "select",
      options: ["sm", "md", "lg"],
      description: "The size of the button",
    },
    loading: {
      control: "boolean",
      description: "Shows a loading spinner and disables interaction",
    },
    disabled: {
      control: "boolean",
      description: "Disables the button",
    },
  },
  args: {
    children: "Button",
    variant: "primary",
    size: "md",
  },
};

export default meta;
type Story = StoryObj<typeof Button>;

// ---- Variant stories ----

export const Primary: Story = {
  args: {
    variant: "primary",
    children: "Primary Action",
  },
};

export const Secondary: Story = {
  args: {
    variant: "secondary",
    children: "Secondary Action",
  },
};

export const Ghost: Story = {
  args: {
    variant: "ghost",
    children: "Ghost Action",
  },
};

export const Danger: Story = {
  args: {
    variant: "danger",
    children: "Delete Item",
  },
};

// ---- Size stories ----

export const Small: Story = {
  args: { size: "sm", children: "Small" },
};

export const Medium: Story = {
  args: { size: "md", children: "Medium" },
};

export const Large: Story = {
  args: { size: "lg", children: "Large" },
};

// ---- State stories ----

export const Loading: Story = {
  args: {
    loading: true,
    children: "Saving...",
  },
};

export const Disabled: Story = {
  args: {
    disabled: true,
    children: "Disabled",
  },
};

// ---- All variants side-by-side ----

export const AllVariants: Story = {
  render: () => (
    <div style={{ display: "flex", gap: "12px", alignItems: "center" }}>
      <Button variant="primary">Primary</Button>
      <Button variant="secondary">Secondary</Button>
      <Button variant="ghost">Ghost</Button>
      <Button variant="danger">Danger</Button>
    </div>
  ),
};

export const AllSizes: Story = {
  render: () => (
    <div style={{ display: "flex", gap: "12px", alignItems: "center" }}>
      <Button size="sm">Small</Button>
      <Button size="md">Medium</Button>
      <Button size="lg">Large</Button>
    </div>
  ),
};

// ---- Interaction test ----

export const ClickInteraction: Story = {
  args: {
    children: "Click Me",
  },
  play: async ({ canvasElement, args }) => {
    const canvas = within(canvasElement);
    const button = canvas.getByRole("button", { name: /click me/i });

    await userEvent.click(button);

    // Verify the button is still in the document after click
    await expect(button).toBeInTheDocument();
    await expect(button).not.toBeDisabled();
  },
};

export const DisabledInteraction: Story = {
  args: {
    children: "Cannot Click",
    disabled: true,
  },
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    const button = canvas.getByRole("button", { name: /cannot click/i });

    await expect(button).toBeDisabled();
  },
};

Visual Regression Testing with Chromatic

Storybook + Visual Regression Testing Pipeline Storybook + Visual Regression Testing Pipeline Write Story*.stories.tsx PR OpenedGitHub Actions ChromaticScreenshot stories Pixel DiffCompare baseline Review + ApproveVisual changes accepted Storybook deployed as static site → Interactive documentation for all teams → Component playground

For teams that want automated visual regression testing, Chromatic integrates with Storybook to capture screenshots of every story on every PR:

.github/workflows/chromatic.yml

name: Chromatic Visual Tests

on:
  pull_request:
    paths:
      - "packages/design-system/src/**"

jobs:
  chromatic:
    name: Visual Regression
    runs-on: ubuntu-latest
    timeout-minutes: 10

    steps:
      - name: Checkout repository
        uses: actions/checkout@v6
        with:
          fetch-depth: 0

      - name: Setup pnpm
        uses: pnpm/action-setup@v4

      - name: Setup Node.js
        uses: actions/setup-node@v6
        with:
          node-version: 22
          cache: "pnpm"

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Run Chromatic
        uses: chromaui/action@latest
        with:
          projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
          workingDir: packages/design-system
          buildScriptName: build-storybook
          onlyChanged: true
          exitZeroOnChanges: true

Storybook Deployment

The built Storybook is deployed as a static site (e.g., to Cloudflare Pages or GitHub Pages) on every merge to main, providing a persistent reference URL for all teams:

https://design-system.example.com

This URL is linked from the monorepo README, onboarding documentation, and the design system package's homepage field in package.json.


Dual Distribution Sync Testing

Because the design system ships through two independent distribution channels (npm package and Module Federation remote), it is important to verify that both outputs remain consistent and produce the same visual and behavioral results. A drift between the two distributions can cause subtle bugs where components look or behave differently depending on how a consuming MFE loads them.

  1. Visual regression tests across both distributions. Extend the Chromatic (or equivalent visual regression) pipeline to capture screenshots of components rendered from both the npm package build and the MF remote build. Compare the snapshots to ensure pixel-level consistency. This can be achieved by maintaining a small test harness application that imports components both ways and runs them through the same Storybook stories.

  2. Snapshot comparisons in CI. Add a CI step that builds both distributions (build:npm and build:mf), renders a representative set of components from each, and compares the HTML output or serialized component trees. Any unexpected diff should fail the build.

  3. Integration test suite. Maintain a lightweight integration test that mounts key components from the npm package and from the MF remote side by side, asserting that their rendered output, applied styles, and interactive behavior (click handlers, keyboard navigation) are equivalent.

  4. Automated drift detection. As part of the release workflow, run the sync tests before publishing. If a visual or behavioral discrepancy is detected between the npm and MF distributions, block the release until the issue is resolved.


Breaking Change Communication

When the design system introduces breaking changes, clear and structured communication is essential to minimize disruption to consuming MFE teams.

Guidelines

  • Deprecation warnings first. Before removing or altering a public API, add runtime deprecation warnings (e.g., console.warn) in the current version. These warnings should reference the specific version that will remove the deprecated feature and suggest the migration path. This gives consuming teams at least one minor release cycle to prepare.

  • Migration guides. For every major version bump or significant breaking change, publish a migration guide alongside the release. The guide should include:

    • A summary of what changed and why.
    • Before/after code examples showing the old API and the new API.
    • Steps to update, including any codemod scripts if applicable.
    • Known edge cases or gotchas.
  • Versioned release notes. Use the Changesets-generated CHANGELOG.md as the primary record of changes. For major releases, supplement the changelog with a dedicated migration document in the repository (e.g., docs/migrations/v3-to-v4.md).

  • Communication channels. Announce breaking changes through the team's established channels (e.g., Slack, email, or an internal developer newsletter) before the release is published. Include a link to the migration guide and an estimated timeline for the deprecation period.

  • Coordinated rollout for MF remotes. Because Module Federation remotes update at runtime without MFE rebuilds, breaking changes to runtime components require special care. Use feature flags or versioned remote entry points (e.g., design-system/v4/mf-manifest.json) to allow MFE teams to opt in to the new version on their own schedule rather than being forced into an immediate upgrade.


References