Angular Material Components: How to Build a Fast, Consistent Design System

Angular Material Components: How to Build a Fast, Consistent Design System

Written for Angular Material 19+ / Angular 22 (released June 3, 2026).

Angular Material gets a team shipping real UI in week one. By month three, the same app has three different “primary action” buttons, a styles.scss with 40 undocumented overrides, and a form field whose padding nobody dares touch because two modules depend on it. That drift is an ownership problem: nobody decided which components belong to the product, which belong to the design system, and which belong to nobody.

This piece gives you a concrete way to decide when to use Angular Material components as-is, when to wrap them in shared components, and when to build custom controls on the CDK instead. It also covers the two things that break most often on upgrade: theme tokens and icon registration.

What Material Gives a Product Team, and What It Does Not

Material Components, CDK Primitives, and Custom Controls

Angular Material ships three layers, and teams often use only the first:

  1. @angular/material: finished, themable UI components like mat-select, mat-slide-toggle, mat-expansion-panel, mat-table, and mat-datepicker.
  2. @angular/cdk: unstyled primitives for building your own components. Overlay, portal, focus trap, live announcer, drag and drop, virtual scroll, listbox, menu, and stepper behavior.
  3. Custom controls: your own components that implement MatFormFieldControl so they sit inside mat-form-field and inherit its label, hint, and error handling.

Here is where the speed shows up. A settings page with grouped preferences, a few toggles, a couple of dropdowns, and collapsible sections is roughly a day’s work with mat-expansion-panel, mat-slide-toggle, mat-select, and mat-form-field. Built from scratch, the dropdown alone costs you overlay positioning, scroll blocking, typeahead, aria-activedescendant wiring, and focus restore on close. That is a week before design review.

The tradeoff: you inherit Material Design’s structure. The internal DOM of mat-select is not your API, and the moment your design calls for a search box inside the panel, you are outside the supported surface.

The Mid-Size Team Pros and Cons Callout

Angular Material for a 10 to 60 person product team

Works well when:

  • Your brand can live inside Material’s shape language with a custom color and type scale.
  • You need accessible date pickers, tables, dialogs, and menus without owning them.
  • You want testing harnesses (MatSelectHarness, MatCheckboxHarness) so tests survive internal DOM changes.
  • You upgrade Angular regularly and want the UI library to move on the same schedule.

Hurts when:

  • Design owns a distinct visual system and treats Material shapes as a starting sketch.
  • Product needs heavy data grids with inline editing, column pinning, and grouping. mat-table will not get you there.
  • Nobody owns the theme file, so overrides land in feature modules.
  • You have one designer producing per-screen variants faster than engineering can standardize them.

Performance, Accessibility, and Modern Browser Support

The Angular team supports the two most recent versions of Chrome, Firefox, Safari, and Edge, and tests against NVDA, JAWS, VoiceOver, and TalkBack. That accessibility baseline is the strongest argument for using components directly: keyboard navigation, ARIA roles, and focus management are already there and already tested.

Performance needs your attention in two places. Standalone imports keep bundles reasonable, but importing MatTableModule and rendering 2,000 rows without cdk-virtual-scroll will still stall on scroll. Ripples are also measurable on low-end mobile devices; disable them globally with MAT_RIPPLE_GLOBAL_OPTIONS if you see input lag.

Today’s takeaway: list every Material component your app imports, then mark which ones a person is actually responsible for. The unmarked ones are your future debt.

Read more: Angular Latest Version: Your Friendly Guide To New Features And Updates In 2026

Choose Direct Use, Shared Wrappers, or a Custom Build

Decision Matrix: Direct Material, Wrapper Component, or CDK-Based Control

What Angular Aria is, for context: Angular Aria is a collection of headless, accessible directives that implement common WAI-ARIA patterns. The directives handle keyboard interactions, ARIA attributes, focus management, and screen reader support. It’s stable as of Angular 22. Think of it as the fourth option between CDK and fully custom: you get accessibility behavior out of the box, but bring your own styles and markup.

Also revise the CDK column’s cost framing: the current matrix presents CDK as the most expensive option (“you write more code once”). With Angular Aria now stable, CDK is the right choice when you need behavior primitives (overlay, drag-and-drop, virtual scroll) but not WAI-ARIA interaction patterns specifically. Angular Aria covers the accessibility pattern use case more directly.

Score the component against these criteria before anyone writes markup:

CriteriaUse Material directlyWrap in a shared componentBuild on Angular AriaBuild custom on the CDK
Usage frequency1–3 places5+ places or across featuresEverywhere: central to the product, needs custom stylingEverywhere: needs behavior Material and Aria don’t expose
Brand divergenceTheme tokens are enoughFixed props and a few token overrides cover itStructure and style must be yours; accessibility behavior must be correctStructure itself must change (custom layout, states, or motion)
Cross-team reuseOne team, one moduleMultiple teams need identical defaultsMultiple teams need an accessible base they can style independentlyMultiple teams need behavior Material’s internal DOM cannot support
Upgrade sensitivityYou touch no internal classesOverrides exist but are containedNo dependency on Material’s internal DOM; Angular Aria’s API is stableYou need immunity from Material’s internal DOM
Accessibility complexityMaterial handles itMaterial handles it; the wrapper adds labelsYou own the markup and style; Angular Aria owns ARIA roles, keyboard nav, and focusYou own roles, focus, and announcements from scratch
Examplemat-divider, mat-progress-bar, mat-tooltipButton, form field, dialog shell, empty stateCombobox, tabs, accordion, listbox, toolbar: any component where you need WAI-ARIA interaction patterns with custom visual designSearchable multi-select with behavior Material and Angular Aria don’t cover, custom data grid, command palette

Rule of thumb: wrap when you enforce defaults, reach for Angular Aria when you need accessible interaction patterns with full visual control, and build custom on the CDK when you need behavior neither Material nor Angular Aria exposes.

Angular Primitives (ng-primitives) is a community alternative in the same headless space as Angular Aria; actively maintained, signals-first, and worth evaluating if you want more component coverage than Angular Aria’s current pattern set or prefer a library that isn’t tied to Angular’s release schedule.

Signals That a Shared Abstraction Has Become Necessary

Three signals matter, and all three are countable in your repo today.

  1. The same override appears in three files. Grep for .mat-mdc- in feature folders. Any class that shows up more than twice belongs in a wrapper or a theme override, not in a page’s stylesheet.
  2. Variant count exceeds design intent. If design defines two button levels and the code has five mat-flat-button variations with different padding, the API is missing.
  3. Config repeats at call sites. Every dialog passing the same width, panelClass, autoFocus, and restoreFocus means you need a dialog service, not more arguments.

A good wrapper is boring. It exposes named props (variant, size, loading), forwards content with <ng-content>, and hides every token override inside its own SCSS.

When a Custom Control Is Cheaper Than Fighting Material

Wrapping is not free. To make a custom control work inside mat-form-field, you implement MatFormFieldControl: stateChanges, setDescribedByIds, onContainerClick, errorState, shouldLabelFloat, plus ControlValueAccessor. That is real work, but it is bounded, and it does not break on upgrade.

Compare that to the alternative. If you need a select with a search input, async paging, and chips, you will end up reaching into mat-select’s panel DOM, overriding overlay height, and intercepting keyboard events the component already handles. Every Material minor release becomes a risk.

At that point, cdk-overlay plus cdk-listbox plus @angular/cdk/a11y is the cheaper path. You write more code once instead of debugging invisible regressions forever.

Today’s takeaway: pick your three most-used components and assign each one to a column in the matrix above. Write the decision down in the repo.

Read more: Angular vs React in 2026: Which One Fits Your Team Structure?

Set Theme Boundaries Before Styling Spreads

Angular Material prebuilt themes are single CSS files you reference from angular.json or import in styles.scss. The Material 3 set includes azure-blue, rose-red, magenta-violet, and cyan-orange. Custom themes use the Sass API and generate tokens from your own palettes.

Angular Material Prebuilt Themes Versus Custom Theme Configuration

FactorPrebuilt themeCustom theme configuration
Setup timeMinutes. One import, no Sass neededHalf a day to a few days, plus palette generation
Design flexibilityFixed palettes and type scaleFull control over color roles, typography, density, shape
MaintainabilityNothing to maintainOne theme file to own, plus per-component override mixins
Upgrade riskLow. The file is replaced for youModerate. Token names and mixin APIs change across majors
Dark modeIncluded via the light-dark prebuilt filesYou define it, usually with color-scheme and one theme mixin
Best forInternal tools, admin panels, prototypes, early-stage MVPsCustomer-facing products with brand requirements

Prebuilt is genuinely enough for internal dashboards. Stop pretending otherwise and spend the time elsewhere.

Apply Angular Material Colors Through M3 Tokens Instead of Local Overrides

In Angular Material 19 and later, mat.theme() emits system-level CSS variables such as –mat-sys-primary, –mat-sys-on-primary, and –mat-sys-surface-container. Your own components should read those variables directly instead of hardcoding hex values.

To set Angular Material colors from a brand color, generate palettes with the ng generate @angular/material:theme-color schematic rather than hand-writing tonal steps. It produces the full tonal ranges M3 expects.

// styles/_theme.scss

@use '@angular/material' as mat;

// Generate palette from brand color using the schematic output

@use './brand-palette' as brand;

html {

  @include mat.theme((

    color: (

      theme-type: light,

      primary: brand.$primary-palette,

      tertiary: brand.$tertiary-palette,

    ),

    typography: mat.$roboto-typography,

    density: -1,  // -1 = compact; 0 = default; -2 = dense

  ));

}

// Dark mode variant

html[data-theme='dark'] {

  @include mat.theme((

    color: (

      theme-type: dark,

      primary: brand.$primary-palette,

    ),

  ));

}

When one component needs to differ, use the per-component override mixins (mat.button-overrides(), mat.form-field-overrides(), mat.icon-button-overrides()) scoped to a class. Do not write::ng-deep .mat-mdc-button { background: … }. The first survives upgrades. The second is a bet on internal class names.

Plan the M2-to-M3 Migration Before Version Upgrades

This is the migration that bites teams right now, and it is worth naming precisely.

M2 themes were built from mat.define-palette() and mat.define-light-theme(), and code read colors with mat.get-color-from-palette($primary, 700). M3 has no hue-700 concept. It has color roles: primary, on-primary, primary-container, surface. There is no automatic one-to-one mapping, so every get-color-from-palette call needs a human decision.

The silent failures are worse than the loud ones. If your app still uses M2 mixins and your custom components expect –mat-sys-* variables, those variables simply do not exist, and colors fall back to inherited values with no build error. The same pattern hit teams on the v15 MDC migration, when .mat-form-field-wrapper became .mat-mdc-form-field-* and padding overrides stopped applying while the build stayed green.

Plan it as its own ticket: inventory every palette function call, every .mat-* selector override, and every density mixin before you bump the version.

Control Density and Typography Without Fragile Selector Overrides

Set density through the density option in mat.theme(), or with the per-component density mixins, not by forcing heights on internal elements.

The classic example: teams shrink form fields by setting height: 0 on .mat-mdc-form-field-subscript-wrapper to remove the hint space. The supported fix is subscriptSizing: ‘dynamic’ provided through MAT_FORM_FIELD_DEFAULT_OPTIONS. Same visual result, no dependency on a class name that can be renamed.

The same logic applies to defaults across the app. MAT_FORM_FIELD_DEFAULT_OPTIONS sets appearance, MAT_TOOLTIP_DEFAULT_OPTIONS sets delays, MAT_DIALOG_DEFAULT_OPTIONS sets width and focus behavior. Injection tokens are documented API. CSS aimed at internals is not.

Today’s takeaway: search your codebase for::ng-deep and get-color-from-palette. Each hit is a line item in your next upgrade estimate.

Standardize Icons, Overrides, and Component Ownership

Register Angular Material Icons and Custom SVG Sets in One Place

Angular Material icons come in two flavors, and mixing them is the most common source of visual inconsistency. Font ligatures (<mat-icon>settings</mat-icon>) and SVG icons (<mat-icon svgIcon=”brand:settings”>) size and align differently.

Register everything once, in a single app-level initializer:

  • Call setDefaultFontSetClass() so nobody has to remember whether the project uses Material Icons or Material Symbols.
  • Load custom sets with addSvgIconSetInNamespace(‘brand’, sanitizer.bypassSecurityTrustResourceUrl(‘assets/brand-icons.svg’)) instead of registering individual icons in feature components.
  • Remember that MatIconRegistry fetches SVGs through HttpClient. Without provideHttpClient(), or during server-side rendering without proper handling, icons silently render as empty boxes.
// app.config.ts — register icons once at app level

import { ApplicationConfig } from '@angular/core';

import { provideHttpClient } from '@angular/common/http';

import { MatIconRegistry } from '@angular/material/icon';

import { DomSanitizer } from '@angular/platform-browser';

export const appConfig: ApplicationConfig = {

  providers: [

    provideHttpClient(),

    {

      provide: APP_INITIALIZER,

      useFactory: (registry: MatIconRegistry, sanitizer: DomSanitizer) => () => {

        registry.setDefaultFontSetClass('material-symbols-outlined');

        registry.addSvgIconSetInNamespace(

          'brand',

          sanitizer.bypassSecurityTrustResourceUrl('assets/brand-icons.svg')

        );

      },

      deps: [MatIconRegistry, DomSanitizer],

      multi: true,

    },

  ],

};

Two more consistency traps. Custom SVGs with hardcoded fill attributes ignore your theme, so strip fills in the asset pipeline and let currentColor apply. And mat-icon defaults to 24px, so a set exported at 20px in a 24px viewBox will look slightly small next to Material’s own icons everywhere.

Use ViewEncapsulation and SCSS Precedence Deliberately

Overlay-based components render into cdk-overlay-container at the document root, outside your component’s DOM. Component-scoped styles never reach them. That is why teams reach for ViewEncapsulation.None and then wonder why a dialog style leaked into an unrelated page.

Use the supported hooks instead: panelClass on MatDialog, MatSelect, MatAutocomplete, and MatMenu, then style that class from a global partial.

Keep precedence simple with three tiers: a theme file for tokens, one global partial per overlay-rendered component, and component-scoped styles for everything you own. If someone needs a fourth tier, that is a signal to build a wrapper.

A Step-by-Step Convention for Wrappers, Override Records, and Theme Token Versions

You can put this in place in one sprint:

  1. Create a ui/ library. Every shared wrapper lives here. Feature modules import from ui/, never from @angular/material for wrapped components.
  2. Name wrappers by role, not by Material. app-button, app-field, app-modal. The name tells you it is yours.
// ui/src/lib/button/app-button.component.ts

import { Component, input } from '@angular/core';

import { MatButtonModule } from '@angular/material/button';

type ButtonVariant = 'primary' | 'secondary' | 'destructive';

@Component({

  selector: 'app-button',

  imports: [MatButtonModule],

  template: `

    <button

      [class]="'app-button app-button--' + variant()"

      mat-flat-button

      [disabled]="disabled()"

    >

      <ng-content />

    </button>

  `,

  styleUrl: './app-button.component.scss',

})

export class AppButtonComponent {

  variant = input<ButtonVariant>('primary');

  disabled = input(false);

}

The wrapper exposes variant and disabled as typed inputs, hides every token override inside its own SCSS, and forwards content with <ng-content>. Feature modules import AppButtonComponent, never MatButtonModule directly.

  1. Add a lint boundary. Use an ESLint no-restricted-imports rule so feature folders cannot import MatButtonModule directly once app-button exists.
  2. Keep an override record. One markdown file: what was overridden, which component, why, which Material version it was verified on, and who approved it. Ten lines per entry, maximum.
  3. Version your theme tokens. Keep _theme.scss under a single owner in CODEOWNERS. Any change to a token gets a changelog entry and a screenshot.
  4. Add a wrapper checklist to PR review. Two questions: does this component already exist in ui/, and does this PR add a .mat-mdc-* selector? If yes to the second, it needs a link to the override record.

Audit AI-Generated Material Code for Deprecated APIs and Usage Drift

Copilot and Cursor were trained on years of Angular Material code, and much of it is old. Expect suggestions using mat.define-light-theme(), ::ng-deep, NgModule-based imports where your app is standalone, and button attribute selectors that newer versions have deprecated in favor of the current appearance API.

The other failure mode is drift. Ask an assistant for a form three times, and you get three different mat-form-field appearance and spacing choices, none of which match your wrapper.

Two defenses. Run ng update @angular/material so official migration schematics catch what they can, and add a grep-based CI check for banned patterns (::ng-deep, direct MatButtonModule imports outside ui/). You can also use an assistant productively here: point it at the repo and ask it to list every distinct usage pattern of a given component. It is good at surfacing the variants a human would miss.

Today’s takeaway: add the banned-pattern grep to CI this week. It costs an hour and stops the bleeding.

Read more: Angular Interview Questions (2026): From Fundamentals to Senior-Level Scenarios

Keep the System Maintainable Through Releases

Test Behavior, Keyboard Flows, and Visual Tokens Across Supported Browsers

Write component tests against CDK testing harnesses, not CSS selectors. MatSelectHarness.clickOptions() keeps working when the internal DOM changes; fixture.nativeElement.querySelector(‘.mat-mdc-select-trigger’) does not.

Cover the keyboard paths that Material gives you for free, and that quietly break: arrow keys and typeahead in mat-select; Escape closing an overlay with focus returning to the trigger; Tab order inside a dialog’s focus trap.

For visuals, snapshot one page per theme (light, dark, and any brand theme) and run it on the latest two versions of Chrome, Firefox, Safari, and Edge. Token changes are invisible in unit tests and obvious in a screenshot diff.

Treat Material Upgrades as Design-System Changes, Not Dependency Updates

A Material major is not a patch bump. Internal class names, token names, and default appearances all move.

Run the upgrade on its own branch. Read the release notes for renamed tokens and deprecated selectors, then walk your override record entry by entry and verify each one still applies. One major at a time, with the Angular framework upgrade, since Material follows the same release schedule.

Budget it like a feature. A mid-size app with a custom theme and a dozen documented overrides is usually a two- to four-day job. Skipping the verification pass is how a form field regresses to default spacing in production with no build error.

Use Documentation and Usage Audits to Retire One-Off Variants

Documentation only works if it is short. One page per wrapper: the props, one usage example, and a line saying which Material component it replaces and why direct use is off-limits.

Then audit quarterly. Count usages of each wrapper and each raw Material component with a simple grep, and put the numbers next to each other. Any variant used once is a candidate for deletion. Any raw Material usage that should be a wrapper is a ticket.

The pattern that keeps systems clean is unglamorous: every component has an owner, every override has a record, and every variant has to justify existing.

Quick reference: component ownership

  • Use Material directly when the component appears in fewer than about three places, theme tokens cover the design, and you touch no internal classes.
  • Wrap it in a shared component when it is used across features or teams, you are enforcing consistent defaults, or overrides already exist in more than two files.
  • Build custom on the CDK when Material’s structure or behavior has to change, the component is central to your product, or you need independence from Material’s internal DOM.
  • Use Angular Aria when you need WAI-ARIA interaction patterns (combobox, tabs, accordion, listbox, toolbar, menu) with full visual control; you bring the markup and styles, Angular Aria handles keyboard navigation, ARIA attributes, and focus management. Stable since Angular 22. This is the right path for custom design systems that need accessibility correctness without inheriting Material’s visual opinions.
  • Always: tokens live in one theme file, overrides live in one record, overlay styles go through panelClass, and icons register in one place.

If your team is weighing whether to build this ownership model in-house or bring in someone who has done it before, Arc can connect you with vetted Angular engineers who have built and maintained Material-based design systems through real version migrations.

⚡️ HireAI matches you with vetted developer shortlists in seconds, not days 

⚡️ Access 450,000+ developers, designers, and marketers, all vetted for skill and communication 

⚡️ Freelance or full-time, ready to interview

Try Arc and hire top talent now →

Frequently Asked Questions

Is Angular Material still the right choice if we’re already using PrimeNG for data grids?

Yes, these aren’t mutually exclusive, and most mid-size teams that reach for PrimeNG’s grid don’t rip out Material everywhere else. A common pattern: Material handles forms, navigation, dialogs, and buttons, while PrimeNG or a CDK-based custom grid handles the data-heavy views Material’s mat-table wasn’t built for (inline editing, column pinning, grouping). 

PrimeNG is the most commonly cited alternative for data-heavy views, but Syncfusion and Kendo UI offer comparable grid capabilities with commercial support, and ng-zorro is worth considering if your organization already uses Ant Design patterns. Run the decision matrix above per component, not per app; the answer can be “both,” scoped by where each library is actually stronger.

How do I change the primary color in Angular Material 19 and later?

Don’t hand-write hex values or hunt for mat-palette(); that’s the deprecated M2 approach, and most tutorials still teach it. Generate a full M3 tonal palette from your brand color with ng generate @angular/material:theme-color, then apply it through mat.theme(). Your components should read the resulting –mat-sys-primary and –mat-sys-on-primary variables directly instead of hardcoding colors, so the theme stays consistent across light, dark, and any brand variant.

Does upgrading Angular Material break custom theme colors?

It can, silently, if you’re still on M2 mixins. M2 reads colors with mat.get-color-from-palette($primary, 700), but M3 has no hue-700 concept — it uses roles like primary, on-primary, and surface, with no automatic mapping between the two systems. If your custom components expect –mat-sys-* variables that don’t exist yet, colors fall back to inherited values with no build error, which is exactly the failure mode covered in the M2-to-M3 migration section above; plan it as its own ticket before you bump the version.

Written by
The Arc Team