Implement a custom Design System on top of Angular Material 🏡

Sylvain DEDIEU

Senior Software Development Engineer - #ui-foundations

Agenda

1

2

4

What is and why a DS ?

How to implement a DS ?

Tips & Tricks

3

Our trade-offs

         s.dedieu@criteo.com

Sylvain DEDIEU

Senior Software Development Engineer - #ui-foundations

         @sdedieu.bsky.social

         s.dedieu@criteo.com

Sylvain Dedieu

Get the presentation slides

🤳

What is & why a DS ?

What is a Design System ?

At its core, a design system is a set of building blocks and standards that help keep the look and feel of products and experiences consistent. Think of it as a blueprint, offering a unified language and structured framework that guides teams through the complex process of creating digital products

What is a Design System ?

What is a Design System ?

Why a Design System ?

Demo time !

Why a Design System ?

Context: Our trade-offs

How "deep" should we build it ?

423 Applications

155 Applications (most internals)

16 Applications (internals)

Cdk

Material

How "deep" should we build it ?

The customization

Material

The (bad) customization

button,
a {
  &.mat-button, &.mat-flat-button, &.mat-icon-button, &.mat-stroked-button {
    line-height: 40px;
    border-radius: 24px;
    &.small {
      line-height: 32px;
    }

    &.large {
      line-height: 48px;
    }
  }
  &.mat-button:hover .mat-button-focus-overlay, &.mat-stroked-button:hover .mat-button-focus-overlay,
  &.mat-icon-button:hover .mat-button-focus-overlay {
    opacity: 0.12;
  }
  &.mat-button, &.mat-raised-button, &.mat-flat-button, &.mat-stroked-button {
    .mat-button-wrapper {
      display: flex;
      flex-direction: row;
      align-items: center;
      justify-content: center;
    }
  }
}

// removing the pure circle on icon button ripple
.mat-icon-button .mat-button-ripple-round {
  border-radius: initial;
}

The issue

Theming

Customization

Customization

Theming

We suffer the Angular Material Components HTML changes

v15

v14

<button mat-button 
  color="primary">
  Primary
</button>
<button mat-button 
  color="primary">
  Primary
</button>

v14

<span class="mat-button-wrapper"><ng-content></ng-content></span>
<span matRipple class="mat-button-ripple"
      [class.mat-button-ripple-round]="isRoundButton || isIconButton"
      [matRippleDisabled]="_isRippleDisabled()"
      [matRippleCentered]="isIconButton"
      [matRippleTrigger]="_getHostElement()"></span>
<span class="mat-button-focus-overlay"></span>
<span
    class="mat-mdc-button-persistent-ripple"
    [class.mdc-button__ripple]="!_isFab"
    [class.mdc-fab__ripple]="_isFab"></span>

<ng-content select=".material-icons:not([iconPositionEnd]), 
  mat-icon:not([iconPositionEnd]), [matButtonIcon]:not([iconPositionEnd])">
</ng-content>

<span class="mdc-button__label"><ng-content></ng-content></span>

<ng-content select=".material-icons[iconPositionEnd], 
  mat-icon[iconPositionEnd], [matButtonIcon][iconPositionEnd]">
</ng-content>

<!--
  The indicator can't be directly on the button, because MDC uses ::before for high contrast
  indication and it can't be on the ripple, because it has a border radius and overflow: hidden.
-->
<span class="mat-mdc-focus-indicator"></span>

<span matRipple class="mat-mdc-button-ripple"
     [matRippleDisabled]="_isRippleDisabled()"
     [matRippleTrigger]="_elementRef.nativeElement"></span>

<span class="mat-mdc-button-touch-target"></span>

v15

Gains

  • Cost saving
    • API Implementation
    • API Documentation
  • Accessibility "ensured"

The way

Material 3 expects tonal palettes, not a legacy 0..9 scale. Adapt CDS CSS variables into the M3 map, add the neutral surface tones, then feed primary and tertiary into mat.define-theme().

// palette level
:root,
:host {
  --palette-sky-50: #f5faff;
  --palette-sky-100: #e5f0fb;
  ...
  --palette-sky-900: #00162b;
  --palette-sky-0: #ffffff;
}

// semantic level
:root,
:host {
  --primary-1: var(--palette-sky-100);
  ...
  --primary-9: var(--palette-sky-900);
  --primary-0: var(--palette-sky-0);
}

// theme level
$material-palette-primary: (
  0: var(--primary-9),
  10: var(--primary-9),
  ...
  100: var(--primary-0),
);
@use 'sass:map';
@use '@angular/material' as mat;
@use './colors/palettes/palette' as palette;
@forward './variables.scss';

$nani-light-theme: mat.define-theme(
  (
    color: (
      theme-type: light,
      primary: palette.$material-palette-primary,
      tertiary: palette.$material-palette-tertiary,
    ),
    density: (
      scale: 0,
    ),
  )
);

Custom theme/palette color

Angular Material typography defines the component text styles: font family, size, weight, line height, and letter spacing. The mat.theme mixin emits those values as
--mat-sys-* tokens.

@use 'sass:map';
@use '@angular/material' as mat;

$nani-typography-plain-family: var(--font-level-p1-reg-font-family); // the font family value
$nani-typography-brand-family: var(--font-level-title-font-family); // the font family value

// Material 3: theme definition
$nani-light-theme: mat.define-theme((
  typography: (
    plain-family: $nani-typography-plain-family,
    brand-family: $nani-typography-brand-family,
  ),
));

// CDS levels stay in our own Sass map.
@function _level($name) {
  @return (
    font-family: var(--font-level-#{$name}-font-family),
    font-weight: var(--font-level-#{$name}-font-weight),
    font-size: var(--font-level-#{$name}-font-size),
    line-height: var(--font-level-#{$name}-line-height),
  );
}

$nani-typography-config: (
  'button': _level('btn-medium'),
  'p1-reg': _level('p1-reg'),
);

// Component overrides consume the level.
$_button-typo: map.get($nani-typography-config, 'button');

@include mat.button-overrides((
  filled-label-text-font: map.get($_button-typo, font-family),
  filled-label-text-size: map.get($_button-typo, font-size),
  filled-label-text-weight: map.get($_button-typo, font-weight),
));

Custom typography

However it's an ongoing process

An internal negociation

@mixin main() {
  .mat-mdc-form-field {
    // medium is the default size
    .mdc-text-field {
      overflow: visible;
      padding: 0;
      margin-bottom: var(--spacing-0-5);
      display: flex;
      align-items: center;
      position: relative;

      &--filled {
        .mdc-floating-label--float-above {
          display: flex;
          align-items: center;
          transform: translateY(-100%) scale(1);

          mat-label {
            display: flex;
            align-items: center;

            .mat-icon {
              margin-left: var(--spacing-0-5);
            }
          }
        }
      }

      &--disabled {
        .mat-mdc-form-field-flex {
          border: none !important;
        }
      }

      &:not(.mdc-text-field--no-label) {
        padding: var(--form-field-padding-top) 0 0 0;
      }

      .mat-mdc-form-field-flex {
        display: flex;
        align-items: center;
        border: var(--form-field-border);
        padding: var(--spacing-1) var(--spacing-2);
        border-radius: var(--form-field-border-radius);
        height: var(--height-5);
        min-height: var(--height-5);

        transition: var(--animation-duration-common) border;

        .mdc-text-field__input[type='search']::-webkit-search-decoration,
        .mdc-text-field__input[type='search']::-webkit-search-cancel-button,
        .mdc-text-field__input[type='search']::-webkit-search-results-button,
        .mdc-text-field__input[type='search']::-webkit-search-results-decoration {
          display: none;
        }

        .mat-mdc-form-field-icon-prefix {
          padding-right: var(--spacing-1);

          .mat-icon {
            padding: var(--spacing-none);
          }
        }

        .mat-mdc-form-field-icon-suffix {
          display: flex;
          padding-left: var(--spacing-1);

          .mat-icon {
            padding: var(--spacing-none);
          }
          .cds-icon-color-picker {
            border-radius: var(--radius-sm);
            border: 1px solid var(--color-border-neutral-field-default);
          }
          .cds-password-visibility-icon,
          .cds-search-remove-icon {
            cursor: pointer;
          }
        }

        .mat-mdc-form-field-infix {
          position: initial;
          display: flex;
          padding: var(--spacing-none);
          min-height: auto;

          .mat-mdc-floating-label {
            top: var(--spacing-2);
            left: var(--spacing-none);
          }
        }

        .mdc-notched-outline__notch,
        .mdc-notched-outline__leading,
        .mdc-notched-outline__trailing {
          border: none;
        }
      }

      .mdc-line-ripple {
        &:before,
        &:after {
          border: none;
        }
      }

      &.mat-mdc-text-field-wrapper {
        flex: none;
      }
    }

    &[cds-label-hidden='true'] {
      .mat-mdc-form-field-required-marker {
        display: none;
      }

      .mdc-text-field {
        padding-top: 0;
      }
    }

    .mat-mdc-form-field-subscript-wrapper {
      // permit the browser to calculate to align with other components
      height: var(--height-2);
    }

    &.no-hint,
    &[cds-subscript-sizing='dynamic'] {
      .mat-mdc-form-field-subscript-wrapper {
        height: auto;
        &::before {
          content: unset;
        }
      }

      .mdc-text-field {
        margin-bottom: 0 !important;
      }
    }

    .mat-mdc-form-field-hint-wrapper,
    .mat-mdc-form-field-error-wrapper {
      padding: var(--spacing-none);

      .mat-mdc-form-field-hint,
      .mat-mdc-form-field-error {
        display: flex;
      }
    }

    &[cds-field-type='textarea'] {
      .mat-mdc-text-field-wrapper {
        .mat-mdc-form-field-flex {
          height: auto !important;
        }
      }

      textarea {
        &.cdk-textarea-autosize {
          // In case cdkAutosizeMinRows is not defined
          min-height: 17px;
        }

        &[cdkautosizeminrows='1'] {
          min-height: 17px !important;
        }
      }
    }

    &[cds-field-type='select'] {
      .mat-mdc-text-field-wrapper {
        .mat-mdc-form-field-flex {
          cursor: pointer;

          mat-label {
            cursor: pointer;
          }
        }
      }
    }
  }
}

Demo time !

Writing a full native library

Cdk

Material

Writing a full native library

@customElement(elementName)
export class Button extends LitElement {

  static override styles = css`
      :host {
        display: block;
        height: fit-content;
      }

      a, button {
        --_color: var(--color, #212121);
        --_background-color: var(--background-color, #1DE9B6);
        --_border-color: var(--border-color, #1DE9B6);
     }`;

  render() {
      const { ariaLabel, ariaHasPopup, ariaExpanded } = this as ARIAMixin;
      return html`
        <button part="button" 
          type=${<'button' | 'submit' | 'reset' | 'menu'>this.elementType} 
          ?disabled="${this.disabled}" 
          ?ripple=${this.ripple} 
          ?color=${this.color} 
          ?size=${this.size}
          fill=${this.fill}
          aria-label="${ariaLabel || nothing}"
          aria-haspopup="${ariaHasPopup || nothing}"
          aria-expanded="${ariaExpanded || nothing}">
          <slot></slot>
        </button>
      `;
    }
}

🤔

Add Wrappers

import { Button as _CoreButton, Props } from '@chameleon/core';
import { ProxyCmp } from '../../utils/utils';

const BUTTON_INPUTS = [ 'color', 'disabled', 'download', ...];

export const BUTTON_PROPS: Props = _CoreButton.props;

@ProxyCmp({
  inputs: BUTTON_INPUTS,
})
@Component({
  selector: `cha-button`,
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: ` <ng-content></ng-content> `,
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
  inputs: BUTTON_INPUTS,
})
export class Button {
  protected el: HTMLElement;

  constructor(
    private readonly _cdr: ChangeDetectorRef,
    private readonly _ref: ElementRef,
    protected zone: NgZone
  ) {
    this._cdr.detach();
    this.el = this._ref.nativeElement;
  }
}

Add Wrappers

export function ProxyCmp(opts: { defineCustomElementFn?: () => void; inputs?: any; methods?: any }) {
  const decorator = function (cls: any) {
    const { defineCustomElementFn, inputs, methods } = opts;

    if (defineCustomElementFn !== undefined) {
      defineCustomElementFn();
    }

    if (inputs) {
      proxyInputs(cls, inputs);
    }
    if (methods) {
      proxyMethods(cls, methods);
    }
    return cls;
  };
  return decorator;
}
export const proxyOutputs = (instance: any, el: any, events: string[]) => {
  events.forEach((eventName) => (instance[eventName] = fromEvent(el, eventName)));
};

export const defineCustomElement = (tagName: string, customElement: any) => {
  if (customElement !== undefined 
      && typeof customElements !== 'undefined' 
      && !customElements.get(tagName)) {
    customElements.define(tagName, customElement);
  }
};

Add Wrappers

export const proxyMethods = (Cmp: any, methods: string[]) => {
  const Prototype = Cmp.prototype;
  methods.forEach((methodName) => {
    Prototype[methodName] = function () {
      const args = arguments;
      return this.zone.runOutsideAngular(() => this.el[methodName].apply(this.el, args));
    };
  });
};
export const proxyInputs = (Cmp: any, inputs: string[]) => {
  const Prototype = Cmp.prototype;
  inputs.forEach((item) => {
    Object.defineProperty(Prototype, item, {
      get() {
        return this.el[item];
      },
      set(val: any) {
        this.zone.runOutsideAngular(() => (this.el[item] = val));
      },
      /**
       * In the event that proxyInputs is called
       * multiple times re-defining these inputs
       * will cause an error to be thrown. As a result
       * we set configurable: true to indicate these
       * properties can be changed.
       */
      configurable: true,
    });
  });
};

Demo time !

Tips & Tricks

Using Angular power for a better DX

export const CDS_BUTTON_HOST = {
  '[attr.cds-size]': '_size || null',
  '[attr.cds-type]': '_type || null',
  '[class]': '_color ? "cds-button cds-" + _color : "cds-button"',
};

@Directive({
  selector: `button[mat-button], button[mat-raised-button],
  button[mat-flat-button], button[mat-stroked-button], 
  button[mat-icon-button], a[mat-button], a[mat-raised-button],
  a[mat-flat-button], a[mat-stroked-button], a[mat-icon-button]`,
  host: CDS_BUTTON_HOST,
})
export class CdsButtonDirective {
  private _size: CdsSize = this._defaultOptions.size ?? 'medium';
  private _type!: CdsType = this._defaultOptions.type;
  private _color!: CdsButtonColor=  this._defaultOptions.color;

  @Input() color: CdsButtonColor;
  @Input('cds-size') size: CdsSize;
  @Input('cds-type') type: CdsType;
  
  constructor(
    @Optional()
    @Inject(CDS_BUTTON_DEFAULT_OPTIONS)
    private _defaultOptions?: CdsButtonDefaultOptions
  ) {}
}
.mdc-button.mat-mdc-button-base {
  &.cds-primary {
    @include mat.button-overrides(
      (
         filled-container-color: var(--color-background-primary-medium-default),
         filled-label-text-color: var(--color-text-primary-medium-default),
         filled-state-layer-color: var(--color-background-primary-medium-default),
      )
    );
  }
  
  &.cds-secondary {
    @include mat.button-overrides(
      (
         filled-container-color: var(--color-background-secondary-medium-default),
         filled-label-text-color: var(--color-text-secondary-medium-default),
         filled-state-layer-color: var(--color-background-secondary-medium-default)
      )
    );
  }
}

Demo time !

Using Angular DI power for a better DX

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

@Directive({
  selector: `table[mat-table], mat-table`,
  providers: [{
      provide: CDS_BUTTON_DEFAULT_OPTIONS,
      useValue: { size: 'small' },
    }],
})
export class CdsTableDirective {}

Demo time !

CdkOverlay management with ShadowDom encapsulation

@Injectable({ providedIn: 'root' })
export class WebComponentOverlayContainer extends OverlayContainer {
  public constructor(
    @Inject(DOCUMENT)
    private readonly document: Document,
    platform: Platform,
    @Inject(SAT_APP_ENTRY_COMPONENT_NAME) private readonly entryComponentName: string
  ) {
    super(document, platform);
  }

  public override getContainerElement(): HTMLElement {
    if (!this._containerElement || !this.getRootElement().querySelector(`.${this._containerElement.className}`)) {
      this._createContainer();
    }
    return this._containerElement!;
  }

  protected override _createContainer(): void {
    super._createContainer();
    this._appendToRootComponent();
  }

  private _appendToRootComponent(): void {
    if (!this._containerElement) {
      return;
    }
    const rootElement = this.getRootElement();
    rootElement.appendChild(this._containerElement);
  }

  private getRootElement(): ShadowRoot | HTMLElement {
    const shellRootComponentSelector = 'ds-shl-root';
    const shellShadowRoot = this.document.querySelector(shellRootComponentSelector)?.shadowRoot;
    return (
      this.document.querySelector(this.entryComponentName)?.shadowRoot ??
      shellShadowRoot?.querySelector(this.entryComponentName)?.shadowRoot ??
      shellShadowRoot ??
      this.document.body
    );
  }
}

Demo time !

Provide Tailwind layout class helpers through your design system library

/** @type {import('tailwindcss').Config} */
const plugin = require('tailwindcss/plugin');

module.exports = {
  corePlugins: [
    'textAlign',
    'display',
    'padding',
    'margin',
    'gap',
    'flex',
    'flexBasis',
    'flexDirection',
    'flexGrow',
    'flexShrink',
    'flexWrap',
    'gridTemplateColumns',
    'alignItems',
    'justifyContent',
    'position',
    'placeSelf',
    'inset',
    'zIndex',
  ],
  theme: {
    extend: {},
  },
  prefix: 'cds-',
  important: true,
};
{
  "$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
  "dest": "../../dist/libs/cds-styles",
  "lib": {
    "styleIncludePaths": [
      "src"
    ]
  },
  "assets": [
    "src/nani-light.scss",
    "tailwind.config.js"
  ]
}
// cds-styles package.json
{
  "name": "@design-system-demo/cds-styles",
  "version": "x.y.z",
  "peerDependencies": {
    "@angular/common": "^19.0.0",
    "@angular/core": "^19.0.0",
    "autoprefixer": "^10.4.20",
    "postcss": "^8.4.49",
    "tailwindcss": "^3.4.17"
  },
}

Demo time !

Thank You !

Get the demo code

👩‍💻🧑‍💻

Opened positions

Implement a custom Design System on top of Angular Material.

By Dedieu Sylvain

Implement a custom Design System on top of Angular Material.

  • 40