Voltar ao Blog
UX & Design31 de julho de 202611 min de leitura

Construindo um Design System com Angular Material

DR

Doug Ribeiro

11 min de leitura

Na Wonderlabs, construí e mantive a biblioteca de componentes Angular do time sobre Angular Material, para um PWA de cartão de crédito que atendia mais de 50.000 usuários. O Storybook entrou só um pouco, perto do final. O objetivo era um sistema que se mantivesse alinhado com a biblioteca do Figma, que nós três do front-end pudéssemos consumir sem lutar contra a API, e que se mantivesse consistente conforme as features eram entregues. Aqui está o que me mordeu e o que eu faria diferente.

Arquitetura de Temas do Angular Material 17+

O Angular Material 17.2 introduziu suporte experimental ao Material Design 3 via um novo sistema de tokens baseado em CSS custom properties. O MD3 só se tornou estável no Angular Material 18. Na prática, se você está em 17.x, precisa optar explicitamente pela API experimental e aceitar que breaking changes podem acontecer. A partir da v18, o fluxo com define-theme produz CSS custom properties com escopo, os estilos dos componentes consomem essas propriedades, e chega de caçar variáveis Sass profundas quando uma cor precisa mudar. A biblioteca da Wonderlabs é anterior a isso: ela vivia no Angular 15 e na API Sass mostrada abaixo. A lição sobre tokens que vem a seguir se aplica às duas. Os trechos de componentes mais abaixo são ilustrativos e usam a sintaxe atual do Angular (blocos @if, inputs obrigatórios) e os nomes atuais de tokens do Material; no Angular 15 você escreveria *ngIf e @Input() puro.

scss
// 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);

Copie esse bloco para um Material atual e ele não compila. O Angular Material 18 renomeou a API M2 com um prefixo m2-: mat.define-palette virou mat.m2-define-palette, mat.define-light-theme virou mat.m2-define-light-theme, mat.define-typography-config virou mat.m2-define-typography-config, e as paletas embutidas também ganharam o prefixo, então mat.$indigo-palette agora é mat.$m2-indigo-palette. Mesmos argumentos, nomes novos. Se você está começando um tema hoje, pule o M2 inteiro e use a API M3 define-theme.

Tokens Primeiro, Componentes Depois

O erro que cometi no início: comecei pelos componentes. Parecia produtivo. Meses depois tínhamos um botão, um form field, um modal e uma tabela de dados, todos construídos antes de a estrutura de tokens estar resolvida, e formalizar os tokens significou voltar em cada um deles para substituir valores hardcoded. Token-first não é conselho teórico, é cicatriz. A regra geral: sem tokens semânticos, uma mudança de cor da marca é uma caçada a hexadecimais hardcoded em cada componente; com eles, é uma edição só.

A camada de tokens fica em um único arquivo importado antes de qualquer estilo de componente, seja você escrevendo ele à mão ou gerando a partir das Figma Variables com o Style Dictionary. Valores brutos mapeiam para nomes semânticos. Nomes semânticos são o que os componentes consomem. Quando a cor da marca muda, você altera um valor bruto e a cascata semântica faz o resto.

scss
/* 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;
}
Escala de cores brutabrand-blue-100 até brand-blue-900. Sem significado semântico, nunca usa… 1 Tokens semânticos--color-primary, --color-surface, --color-on-error. Aliases que depende… 2 Overrides de tokens de componentes Material--mat-button-filled-container-color aponta para tokens semânticos 3 Overrides com escopo de componenteÚltimo recurso. CSS custom properties do componente para edge cases 4

Estendendo o Material Sem Esconder Sua API

Um erro comum é envolver componentes do Material em wrappers gordos que expõem uma API completamente customizada. O desenvolvedor consumidor agora tem que aprender seu wrapper e perdeu acesso a todos os inputs do Material que realmente precisa. Eles começam a furar a abstração com ViewChild e ElementRef. Você piorou as coisas.

A abordagem melhor é usar wrappers finos que adicionam o comportamento específico necessário e deixam o Material fazer o resto. Para customização de variantes, CSS custom property overrides com escopo em um atributo host funcionam de forma limpa sem tocar na superfície TypeScript do componente.

typescript
// 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);
  }
}

Campos de Formulário com Tratamento Consistente de Erros

Em um produto de cartão de crédito, mensagens de erro de validação não são um detalhe secundário de UX. Depois que o texto foi revisado, ele não deveria variar de tela para tela. Um wrapper de form field no design system centraliza isso: um único lugar onde toda a lógica de mensagens de erro vive, com chaves de i18n, para que ninguém no time consiga acidentalmente entregar um texto fora do roteiro.

typescript
@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;
}

O Que Realmente Importa em Escala

  • Versione a biblioteca como um pacote npm separado desde o primeiro dia. Mesmo que comece em um monorepo, o versionamento cria responsabilidade sobre breaking changes
  • Documente cada variante de componente antes de implementar. Elas são documentação viva e critérios de aceite simultaneamente
  • WCAG 2.1 AA é inegociável em aplicações financeiras. Atributos ARIA, navegação por teclado e taxas de contraste são incorporados nos componentes, não deixados para os times de produto
  • Nomes de tokens semânticos sobrevivem a rebrands. --color-action-primary sobrevive a uma atualização de marca. --color-indigo-700 não
  • Todo novo componente adicionado à biblioteca passava por uma revisão de design e pelo code review de um segundo desenvolvedor, mesmo em um time de três pessoas. Sem processo, o DS vira um depósito de lixo
  • Quando você renomeia um componente ou muda uma API, forneça um schematic para que os times consumidores não fiquem presos em versões antigas indefinidamente

Um design system não é uma biblioteca de componentes. É uma linguagem compartilhada entre design e engenharia. Se designers não estão contribuindo para ele e desenvolvedores não estão consultando-o primeiro, ele já falhou.

Angular Material lida com acessibilidade, infraestrutura de temas e comportamento de componentes em um nível de qualidade que eu não vou replicar do zero. Construir em cima dele é a decisão certa. O investimento real está no sistema de tokens, no design de API dos componentes, na documentação e na governança que o mantém vivo e confiável. Acerte isso e a implementação técnica é a parte fácil.

Referências

AngularAngular MaterialDesign SystemUX DesignTypeScript