En Wonderlabs construí y mantuve la librería de componentes Angular del equipo sobre Angular Material, para una PWA de tarjeta de crédito que servía a más de 50.000 usuarios. Storybook entró solo un poco, cerca del final. El objetivo era un sistema que se mantuviera alineado con la librería de Figma, que los tres que estábamos en el front end pudiéramos consumir sin pelear con la API, y que se mantuviera consistente a medida que se lanzaban features. Esto es lo que me mordió y lo que haría diferente.
Arquitectura de theming de Angular Material 18+
Material Design 3 llegó como opt-in experimental en Angular Material 17.2 y pasó a ser la base estable en la versión 18. La migración introdujo un sistema de theming basado en CSS custom properties construido sobre tokens de Material Design 3. Esto reemplazó el enfoque anterior basado en mixins de Sass y es una mejora genuina. Defines un theme usando define-theme, que produce CSS custom properties con scope. Los estilos de componentes consumen esas propiedades. No más búsqueda profunda de variables Sass cuando un color necesita cambiar. La librería de Wonderlabs es anterior a eso: vivía en Angular 15 y en la API de Sass que muestro abajo. La lección sobre tokens que sigue aplica a ambas. Los snippets de componentes más abajo son ilustrativos y usan sintaxis actual de Angular (bloques @if, inputs requeridos) y nombres actuales de tokens de Material; en Angular 15 escribirías *ngIf y @Input() a secas.
// styles/_theme.scss
@use '@angular/material' as mat;
$primary-palette: mat.define-palette(mat.$indigo-palette, 700, 400, 900);
$accent-palette: mat.define-palette(mat.$amber-palette, A200);
$light-theme: mat.define-light-theme((
color: (
primary: $primary-palette,
accent: $accent-palette
),
typography: mat.define-typography-config(
$font-family: 'Inter, sans-serif',
$headline-4: mat.define-typography-level(2rem, 2.5rem, 700),
$body-1: mat.define-typography-level(1rem, 1.5rem, 400)
),
density: 0
));
@include mat.all-component-themes($light-theme);Copia ese bloque a un Material actual y no compila. Angular Material 18 renombró la API M2 con un prefijo m2-: mat.define-palette pasó a ser mat.m2-define-palette, mat.define-light-theme pasó a ser mat.m2-define-light-theme, mat.define-typography-config pasó a ser mat.m2-define-typography-config, y las paletas integradas también tomaron el prefijo, así que mat.$indigo-palette ahora es mat.$m2-indigo-palette. Mismos argumentos, nombres nuevos. Si arrancas un theme hoy, sáltate M2 por completo y usa la API M3 define-theme.
Tokens primero, componentes después
El error que cometí al principio: empecé con componentes. Se sentía productivo. Meses después teníamos un botón, un form field, un modal y una data table, todos construidos antes de que la estructura de tokens estuviera resuelta, y formalizar los tokens significó volver a pasar por cada uno para reemplazar valores hardcodeados. Token-first no es un consejo teórico, es cicatriz. La regla general: sin tokens semánticos, un cambio de color de marca es una cacería de hexadecimales hardcodeados en cada componente; con ellos, es una sola edición.
La capa de tokens vive en un solo archivo importado antes de cualquier estilo de componente, ya sea que lo escribas a mano o lo generes desde Figma Variables con Style Dictionary. Los valores raw se mapean a nombres semánticos. Los nombres semánticos son lo que consumen los componentes. Cuando el color de marca cambia, cambias un valor raw y la cascada semántica hace el resto.
/* styles/_tokens.scss */
:root {
/* Raw color scale */
--color-primary-500: #3949ab;
--color-primary-700: #283593;
--color-primary-900: #1a237e;
--color-error-600: #d32f2f;
--color-error-700: #c62828;
/* Semantic tokens, the only layer components reference */
--color-action-primary: var(--color-primary-700);
--color-action-primary-hover: var(--color-primary-900);
--color-text-primary: #1a1a2e;
--color-text-secondary: #4a4a6a;
--color-surface: #ffffff;
--color-surface-variant: #f4f4f8;
/* Spacing scale */
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
--spacing-xl: 32px;
--spacing-2xl: 48px;
/* Border radii */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 16px;
--radius-pill: 9999px;
}Extendiendo Material sin ocultar su API
Un error común es envolver componentes de Material en wrappers gordos que exponen una API completamente personalizada. El developer que consume ahora tiene que aprender tu wrapper y ha perdido acceso a todos los inputs de Material que realmente necesita. Empiezan a perforar la abstracción con ViewChild y ElementRef. Has empeorado las cosas.
El mejor enfoque son wrappers delgados que agregan el comportamiento específico que necesitas y dejan que Material haga el resto. Para personalización de variantes, los overrides de CSS custom properties con scope a un atributo host funcionan limpiamente sin tocar la superficie TypeScript del componente.
// ds-button.component.ts: a thin wrapper, not a replacement
@Component({
selector: 'ds-button',
standalone: true,
imports: [MatButtonModule],
host: { '[attr.variant]': 'variant' },
template: `
<button mat-flat-button [disabled]="disabled">
<ng-content />
</button>
`
})
export class DsButtonComponent {
@Input() variant: 'primary' | 'secondary' | 'destructive' | 'ghost' = 'primary';
@Input() disabled = false;
}
// ds-button.component.scss
:host([variant='destructive']) {
--mat-button-filled-container-color: var(--color-error-600);
--mat-button-filled-label-text-color: white;
&:hover button {
--mat-button-filled-container-color: var(--color-error-700);
}
}Form Fields con manejo consistente de errores
En un producto de tarjeta de crédito, los mensajes de error de validación no son algo secundario de UX. Una vez que el texto fue revisado, no debería derivar de pantalla en pantalla. Un wrapper de form field en el design system centraliza eso: un solo lugar donde vive toda la lógica de mensajes de error, con claves de i18n, para que nadie en el equipo pueda accidentalmente lanzar copy fuera de guion.
@Component({
selector: 'ds-form-field',
standalone: true,
imports: [MatFormFieldModule, MatInputModule, ReactiveFormsModule, TranslateModule],
template: `
<mat-form-field appearance="outline" [subscriptSizing]="'dynamic'">
<mat-label>{{ label | translate }}</mat-label>
<!-- The wrapper renders the input itself: mat-form-field cannot find
a matInput that is projected into it through ng-content -->
<input matInput [formControl]="control" />
@if (control.hasError('required') && control.touched) {
<mat-error>{{ 'validation.required' | translate }}</mat-error>
}
@if (control.hasError('minlength') && control.touched) {
<mat-error>
{{ 'validation.minLength' | translate:{ min: control.errors?.['minlength']?.requiredLength } }}
</mat-error>
}
</mat-form-field>
`
})
export class DsFormFieldComponent {
@Input({ required: true }) label!: string;
@Input({ required: true }) control!: FormControl;
}Lo que realmente importa a escala
- Versiona la librería como un paquete npm separado desde el día uno. Incluso si empieza en un monorepo, el versionado crea responsabilidad para breaking changes
- Documenta cada variante de componente antes de implementar. Son documentación viva y criterios de aceptación simultáneamente
- WCAG 2.1 AA no es negociable en aplicaciones financieras. Los atributos ARIA, la navegación por teclado, y las ratios de contraste están integrados en los componentes, no se dejan a los equipos de producto
- Los nombres de tokens semánticos sobreviven rebrands. --color-action-primary sobrevive un refresh de marca. --color-indigo-700 no
- Cada nuevo componente agregado a la librería pasaba por una revisión de diseño y una code review de un segundo desarrollador, incluso en un equipo de tres personas. Sin proceso, el DS se convierte en un depósito de basura
- Cuando renombras un componente o cambias una API, provee un schematic para que los equipos que consumen no se queden en versiones antiguas indefinidamente
Un design system no es una librería de componentes. Es un lenguaje compartido entre diseño e ingeniería. Si los diseñadores no contribuyen a él y los developers no lo consultan primero, ya ha fallado.
Angular Material maneja accesibilidad, infraestructura de theming, y comportamiento de componentes a un nivel de calidad que no voy a replicar desde cero. Construir sobre él es la decisión correcta. La verdadera inversión está en el sistema de tokens, el diseño de API de componentes, la documentación, y la gobernanza que lo mantiene vivo y confiable. Haz eso bien y la implementación técnica es la parte fácil.
Referencias
- Angular Material theming. https://material.angular.io/guide/theming
- Material Design 3 tokens. https://m3.material.io/foundations/design-tokens/overview
- Storybook for Angular. https://storybook.js.org/docs/angular/get-started/introduction
- Angular CLI schematics. https://angular.dev/tools/cli/schematics