Design System
Tabla de Contenidos
- Descripcion general
- Estructura del paquete
- Configuracion de build
- Versionado con Changesets
- Publicacion en GitHub Packages
- Consumo del Design System
- Theming y estrategia CSS
- Storybook
- Testing de sincronizacion entre distribuciones
- Comunicacion de breaking changes
- Referencias
Descripcion general
El design system proporciona una biblioteca de componentes compartida, design tokens, hooks y utilidades a cada micro frontend (MFE) de la plataforma. Sigue un modelo de distribucion dual para satisfacer dos necesidades de consumo fundamentalmente distintas:
| Canal de distribucion | Que entrega | Cuando se usa |
|---|---|---|
| Paquete npm | Tipos TypeScript, design tokens, definiciones de variables CSS, funciones utilitarias, hooks compartidos | Build-time -- consumido por bundlers y type checkers durante el desarrollo y CI |
| Module Federation remote | Componentes React compilados, listos para runtime | Runtime -- cargados por el shell o los MFEs en el navegador, sin necesidad de rebuild |
Por que distribucion dual?
Un unico canal de distribucion no puede cubrir ambas necesidades de forma eficiente:
-
npm solo obligaria a cada MFE a empaquetar su propia copia de los componentes del design system. Cualquier actualizacion del design system requeriria rebuild y redeploy de cada MFE dependiente. Sin embargo, npm es el vehiculo natural para artefactos de build-time: declaraciones de tipos TypeScript, constantes de tokens consumidas por IntelliSense del IDE, y funciones utilitarias que se reducen via tree-shaking a solo lo que cada MFE importa.
-
Module Federation solo no puede exponer tipos TypeScript en build-time. Los editores y el type-checking en CI dependen de archivos
.d.tsque deben estar presentes en disco antes de que la aplicacion se ejecute. Los MF remotes se resuelven en runtime en el navegador, lo cual es demasiado tarde para el analisis estatico.
El modelo dual da a los equipos lo mejor de ambos mundos: seguridad de tipos completa y acceso a tokens en build-time via el paquete npm, y actualizaciones de componentes en runtime sin rebuild via el Module Federation remote. Cuando el design system publica un patch (por ejemplo, ajustar el border-radius de un boton), cada MFE recoge el cambio en su siguiente carga de pagina sin ningun redeploy.
El codigo fuente del design system reside en el monorepo bajo packages/design-system/ y es mantenido por el equipo de plataforma junto con la aplicacion shell y los paquetes de infraestructura compartida.
Estructura del paquete
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
Cada componente sigue una estructura interna consistente:
Component.tsx-- la implementacion React, que acepta props tipados y usa CSS Modules para el estilado.Component.module.css-- CSS con scope que compila a nombres de clase unicos, previniendo colisiones entre MFEs.Component.test.tsx-- tests colocados junto al componente usando Vitest y Testing Library.index.ts-- archivo barrel que reexporta el componente y sus tipos de props.
Configuracion de build
Distribucion npm (tsup)
tsup compila el paquete en salidas ESM y CommonJS tree-shakeable con declaraciones TypeScript completas. Los multiples entry points permiten a los consumidores importar solo lo que necesitan, manteniendo los tamanos de bundle al minimo.
Nota: tsup esta actualmente en modo mantenimiento. El mantenedor ha redirigido su foco a tsdown, una alternativa emergente construida sobre Rolldown. Para proyectos nuevos, vale la pena evaluar tsdown como reemplazo. Para proyectos existentes como este, tsup sigue siendo estable y funcional, pero los equipos deben tener en cuenta la cadencia de mantenimiento reducida al planificar decisiones de tooling a largo plazo.
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",
});
Decisiones clave:
- Multiples entry points (
index,tokens,hooks,utils,types) para que los consumidores puedan hacer deep-import de sub-paths sin cargar el paquete completo. external: ["react", "react-dom"]asegura que React no se empaquete -- la aplicacion consumidora proporciona su propia copia.splitting: truehabilita code splitting dentro de la salida ESM para que los modulos internos compartidos no se dupliquen.dts: truegenera archivos.d.tsjunto a cada salida, dando a los consumidores IntelliSense completo.
Mapa de exports en package.json
El campo exports usa conditional exports de Node.js para dirigir a bundlers y runtimes al formato correcto:
{
"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"
}
}
La declaracion "sideEffects": false indica a los bundlers que cualquier export no usado puede eliminarse de forma segura via tree-shaking. El export separado "./styles.css" da a los consumidores un path explicito de opt-in para la hoja de estilos base si la necesitan.
Importante: CSS Modules y
sideEffects. Aunque"sideEffects": falsees correcto para exports puros de JavaScript y TypeScript, los CSS Modules son side effects -- importar un archivo.module.cssprovoca la inyeccion de estilos en el documento, y los bundlers pueden eliminarlos incorrectamente via tree-shaking si no estan listados de forma explicita. Si el paquete incluye salidas CSS que los consumidores importan directamente, hay que actualizar el campo para declarar los archivos CSS como side effects:"sideEffects": ["**/*.css"]Esto asegura que los bundlers preserven los imports CSS durante el tree-shaking mientras siguen eliminando los exports JavaScript no usados.
Module Federation Remote
La configuracion de Rsbuild expone componentes React listos para runtime via Module Federation v2. Son cargados por la aplicacion shell y los MFEs en runtime -- sin necesidad de npm install ni rebuild del lado del consumidor.
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",
},
},
});
Decisiones clave:
singleton: truepara React y ReactDOM asegura que cada MFE comparta exactamente una instancia de React. Sin esto, los hooks fallan por multiples copias de React.manifest: { filePath: "mf-manifest.json" }genera un archivo manifest que el shell lee en runtime para descubrir los modulos disponibles y sus URLs de assets. Este manifest se despliega al CDN junto con los assets compilados.- Directorio de salida separado (
dist-mf) mantiene los artefactos del build MF separados de la distribucion npm endist/, evitando interferencias entre ambas pipelines. - Filenames con content-hash habilitan caching agresivo en CDN -- los assets son inmutables y se invalidan automaticamente en cada cambio.
assetPrefix: "auto"permite al runtime resolver URLs de assets relativas a la ubicacion deremoteEntry.js, lo cual es critico cuando los assets se sirven desde un CDN con un origen diferente al de la aplicacion host.
Versionado con Changesets
El monorepo usa Changesets para versionado semantico independiente de cada paquete. Cada paquete en el workspace puede versionarse y publicarse de forma independiente, de modo que un patch al design system no fuerza un bump de version en paquetes no relacionados.
Configuracion
.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
}
}
Notas de configuracion:
"access": "restricted"porque los paquetes se publican en GitHub Packages bajo el scope de la organizacion, no en el registro publico de npm."updateInternalDependencies": "patch"asegura que cuando un paquete compartido como el design system obtiene una nueva version, cualquier paquete del monorepo que dependa de el tenga su rango de dependencia actualizado automaticamente."changelog": ["@changesets/changelog-github", ...]genera changelogs que enlazan al PR y commit en GitHub, facilitando rastrear por que se hizo un cambio."privatePackages": { "version": false, "tag": false }previene que los paquetes privados (como la app shell, que se despliega pero nunca se publica a npm) sean versionados o tagueados por Changesets.
Flujo de trabajo
El flujo de release tiene cuatro etapas:
-
El desarrollador agrega un changeset. Cuando un PR modifica el design system, el desarrollador ejecuta
pnpm changesety selecciona los paquetes afectados, el tipo de bump semver (patch, minor, major), y escribe un resumen legible. -
El archivo changeset se commitea con el PR. El changeset es un pequeno archivo Markdown en
.changeset/que registra la intencion:
.changeset/blue-dogs-dance.md (ejemplo)
---
"@org/design-system": minor
---
Add new `Tooltip` component with configurable placement and delay. Supports both hover
and focus triggers for accessibility compliance.
-
La GitHub Action de Changesets crea un PR de release. Despues de que el PR de feature se mergea a
main, el bot de Changesets agrega todos los changesets pendientes en un unico PR "Version Packages". Este PR actualiza versiones enpackage.json, escribe entradas enCHANGELOG.mdy elimina los archivos changeset consumidos. -
Mergear el PR de release publica a npm. Cuando el equipo mergea el PR "Version Packages", un workflow de CI ejecuta
changeset publish, que publica la nueva version a GitHub Packages y crea un Git tag.
Como saben los equipos MFE consumidores cuando actualizar
Para MFEs en polyrepos externos que dependen de @org/design-system como paquete npm (para tipos y tokens), Renovate Bot esta configurado para monitorear GitHub Packages y crear PRs automaticamente cuando se publica una nueva version. Los equipos revisan el PR autogenerado, verifican que sus tipos sigan compilando y lo mergean.
Para el Module Federation remote, los MFEs no necesitan hacer nada. El shell carga el ultimo design system remote en runtime basandose en el MF manifest, de modo que los componentes runtime se actualizan automaticamente con cada deploy del design system.
Publicacion en GitHub Packages
Todos los paquetes del monorepo se publican en GitHub Packages bajo el scope de la organizacion @org.
Configuracion del registro
.npmrc en la raiz del monorepo
@org:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}
always-auth=true
.npmrc del polyrepo consumidor
@org:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}
El GITHUB_TOKEN se inyecta como variable de entorno -- en CI proviene del secrets.GITHUB_TOKEN de GitHub Actions o de un PAT de grano fino, y en local los desarrolladores usan un PAT almacenado en su perfil de shell o un gestor de credenciales.
Workflow de publicacion CI/CD
.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
El workflow maneja dos escenarios en cada push a main:
- Si hay changesets pendientes: La action crea (o actualiza) un PR "Version Packages" que bumpa versiones y actualiza changelogs. No se publica ningun paquete todavia.
- Si no hay changesets pendientes (es decir, el PR "Version Packages" se acaba de mergear): La action ejecuta
changeset publish, que publica cada paquete con nueva version a GitHub Packages y crea Git tags.
El paso final despliega condicionalmente el Module Federation remote del design system solo cuando el propio design system esta entre los paquetes publicados.
Consumo del Design System
Como paquete npm (build-time)
Los equipos MFE instalan el design system como dev dependency (ya que los componentes runtime vienen via Module Federation, el paquete npm solo se necesita durante el desarrollo y CI):
pnpm add -D @org/design-system
Importando tipos y props de componentes:
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(),
};
Importando 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,
},
},
};
Importando hooks compartidos:
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>
);
}
Importando funciones utilitarias:
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");
Como Module Federation Remote (runtime)
En runtime, los componentes React reales se cargan via Module Federation. La aplicacion shell registra el design system como remote en su configuracion:
rsbuild.config.ts del Shell (extracto relevante):
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" },
},
}),
Import dinamico directo en un 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;
Patron de paquete wrapper ligero
Para ofrecer una experiencia de desarrollo limpia que se sienta como importar de un paquete regular, un paquete wrapper ligero reexporta cada componente del design system como un componente React lazy-loaded. Los desarrolladores de MFEs nunca necesitan llamar a loadRemote directamente.
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 };
Uso en un MFE (se siente como un import regular):
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>
);
}
Este patron da a los desarrolladores de MFEs:
- Seguridad de tipos -- los props se verifican en build-time via los tipos de
@org/design-system. - Code splitting automatico -- los componentes se cargan bajo demanda.
- Estados de carga elegantes -- cada wrapper incluye un skeleton fallback construido a medida.
- Deploys desacoplados -- el equipo del design system puede publicar actualizaciones visuales sin que los equipos MFE necesiten rebuild.
Theming y estrategia CSS
Estructura de design tokens
Los design tokens son la fuente unica de verdad para las decisiones visuales. Se definen como constantes TypeScript y se compilan a CSS custom properties en 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;
Generacion de CSS custom properties
Los tokens se compilan a CSS custom properties para que los componentes puedan referenciarlos en CSS Modules sin importar JavaScript:
src/tokens/css-variables.ts (utilidad de build-time)
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}`;
}
Esto genera CSS como:
:root {
--ds-color-primary-50: #EFF6FF;
--ds-color-primary-100: #DBEAFE;
/* ... */
--ds-space-xs: 4px;
--ds-space-sm: 8px;
--ds-space-md: 16px;
/* ... */
}
Componente ThemeProvider
El ThemeProvider gestiona el estado del tema y aplica CSS custom properties a un elemento raiz. Soporta tanto deteccion de preferencia del sistema como overrides manuales.
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 para scope de componentes
Cada componente usa CSS Modules para evitar colisiones de estilos entre MFEs. Los nombres de clase se hashean en build-time, asegurando que una clase .button en el design system nunca pueda entrar en conflicto con una clase .button en un 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>
);
}
Estrategia de modo oscuro
El modo oscuro se gestiona mediante dos mecanismos complementarios:
-
Deteccion de preferencia del sistema -- El
ThemeProviderescuchaprefers-color-schemeviamatchMediay resuelve automaticamente a la preferencia del SO del usuario cuando el modo esta configurado como"system". -
Toggle manual -- Los usuarios pueden sobreescribir la preferencia del sistema con una eleccion explicita de
"light"o"dark", que se persiste enlocalStoragey sobrevive a recargas de pagina.
Todos los tokens semanticos (fondos, colores de texto, bordes, sombras) se definen como CSS custom properties que cambian segun el tema activo. Los componentes referencian estos tokens semanticos en lugar de valores de color crudos, de modo que el soporte de modo oscuro es automatico para cada componente.
/* 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 sirve como documentacion interactiva, entorno de testing visual y playground de componentes para el design system.
Configuracion
.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;
Ejemplo de story
Las stories se organizan por componente, con cada archivo de story demostrando todas las variantes, estados y patrones de interaccion.
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();
},
};
Testing de regresion visual con Chromatic
Para equipos que quieran testing de regresion visual automatizado, Chromatic se integra con Storybook para capturar screenshots de cada story en cada 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
Deploy de Storybook
El Storybook compilado se despliega como sitio estatico (por ejemplo, en Cloudflare Pages o GitHub Pages) en cada merge a main, proporcionando una URL de referencia persistente para todos los equipos:
https://design-system.example.com
Esta URL se enlaza desde el README del monorepo, la documentacion de onboarding y el campo homepage del package.json del design system.
Testing de sincronizacion entre distribuciones
Dado que el design system se distribuye a traves de dos canales independientes (paquete npm y Module Federation remote), es importante verificar que ambas salidas se mantengan consistentes y produzcan los mismos resultados visuales y de comportamiento. Un drift entre las dos distribuciones puede causar bugs sutiles donde los componentes se ven o se comportan de forma diferente segun como los carga el MFE consumidor.
Estrategia de testing recomendada
-
Tests de regresion visual en ambas distribuciones. Extender la pipeline de Chromatic (o equivalente de regresion visual) para capturar screenshots de componentes renderizados tanto desde el build del paquete npm como desde el build del MF remote. Comparar los snapshots para asegurar consistencia a nivel de pixel. Esto se puede lograr manteniendo una pequena aplicacion de test harness que importe componentes de ambas formas y los ejecute a traves de las mismas stories de Storybook.
-
Comparaciones de snapshots en CI. Agregar un paso de CI que construya ambas distribuciones (
build:npmybuild:mf), renderice un conjunto representativo de componentes de cada una y compare la salida HTML o los arboles de componentes serializados. Cualquier diff inesperado debe romper el build. -
Suite de tests de integracion. Mantener un test de integracion ligero que monte componentes clave desde el paquete npm y desde el MF remote en paralelo, verificando que su salida renderizada, estilos aplicados y comportamiento interactivo (click handlers, navegacion por teclado) sean equivalentes.
-
Deteccion automatizada de drift. Como parte del workflow de release, ejecutar los tests de sincronizacion antes de publicar. Si se detecta una discrepancia visual o de comportamiento entre las distribuciones npm y MF, bloquear el release hasta que se resuelva el problema.
Comunicacion de breaking changes
Cuando el design system introduce breaking changes, una comunicacion clara y estructurada es esencial para minimizar la disrupcion a los equipos MFE consumidores.
Directrices
-
Warnings de deprecacion primero. Antes de eliminar o alterar una API publica, agregar warnings de deprecacion en runtime (por ejemplo,
console.warn) en la version actual. Estos warnings deben referenciar la version especifica que eliminara la funcionalidad deprecated y sugerir la ruta de migracion. Esto da a los equipos consumidores al menos un ciclo de release minor para prepararse. -
Guias de migracion. Para cada bump de version major o breaking change significativo, publicar una guia de migracion junto con el release. La guia debe incluir:
- Un resumen de que cambio y por que.
- Ejemplos de codigo antes/despues mostrando la API antigua y la nueva.
- Pasos para actualizar, incluyendo scripts de codemod si corresponde.
- Casos borde conocidos o gotchas.
-
Notas de release versionadas. Usar el
CHANGELOG.mdgenerado por Changesets como registro principal de cambios. Para releases major, complementar el changelog con un documento de migracion dedicado en el repositorio (por ejemplo,docs/migrations/v3-to-v4.md). -
Canales de comunicacion. Anunciar breaking changes a traves de los canales establecidos del equipo (por ejemplo, Slack, email o un newsletter interno de desarrolladores) antes de que se publique el release. Incluir un enlace a la guia de migracion y un timeline estimado para el periodo de deprecacion.
-
Rollout coordinado para MF remotes. Dado que los Module Federation remotes se actualizan en runtime sin rebuilds de MFEs, los breaking changes en componentes runtime requieren cuidado especial. Usar feature flags o entry points de remote versionados (por ejemplo,
design-system/v4/mf-manifest.json) para permitir a los equipos MFE optar por la nueva version a su propio ritmo en lugar de forzar una actualizacion inmediata.
Referencias
- tsup -- Bundler para la distribucion npm (modo mantenimiento): https://tsup.egoist.dev/
- tsdown -- Alternativa emergente a tsup construida sobre Rolldown: https://github.com/nicepkg/tsdown
- Changesets -- Gestion de versionado y changelogs: https://github.com/changesets/changesets
- Storybook 10 -- Documentacion y testing de componentes: https://storybook.js.org/
- Module Federation v2 -- Comparticion de modulos en runtime: https://module-federation.io/
- Rsbuild -- Herramienta de build basada en Rspack: https://rsbuild.dev/
- Chromatic -- Testing de regresion visual para Storybook: https://www.chromatic.com/
- GitHub Packages -- Registro npm para organizaciones: https://docs.github.com/en/packages
- CSS Modules -- Nombres de clase CSS con scope: https://github.com/css-modules/css-modules
- Renovate Bot -- Actualizaciones automatizadas de dependencias: https://docs.renovatebot.com/