During a recent project, I built a button component for a Design System that sits on top of Angular Material. The work surfaced several tricky problems worth discussing, so I'll walk through them in this series.

When building a custom Design System on a third-party component library like Angular Material, two main strategies exist:

  1. Apply a custom Angular Material theme and use the library’s components directly as documented.
  2. Create a custom Angular Material theme and then construct your own component library by wrapping the original components inside your own wrappers.

Both approaches carry their own trade-offs. That comparison is a separate discussion, so I won’t dive into it here. In our case, we went with the second option.

My job was to encapsulate an Angular Material button inside our own component and make that available to the wider app. This piece explores how that was done.

Let's walk through the reproduction of that setup in this series.

Goals

Before writing any code, I defined a few baseline goals:

  • Give the component the selector my-button.
  • Place the button text between the opening and closing tags of the custom component.
  • Support three distinct visual modes—primary, secondary, and text—so the same button adapts to different contexts.

Single component versus multiple components

Given the second goal, one might wonder whether to build a single component that switches styles via an Input property, or generate three separate components—one for each style. I think a single component wins because consumers of the Design System then have only one way to render a button, and it's simpler to add new variants later.

That led to two additional goals:

  • The component needs a type input that accepts one of these values: “primary”, “secondary”, or “text”. If nothing is passed, the default should be “text”.
  • The value of type drives which visual style appears, as shown in the following mock-up.

One button component with three different styles

What approach would you take?

Implementation

There are multiple ways to approach this problem. I'll share my solutions here and in subsequent posts. If you have an alternative approach, feedback, or suggestions for improvement, I'd like to hear from you.

First attempt

Let's begin with a straightforward setup to see if it works.

// my-button.component.ts
import { Component, Input } from "@angular/core";

@Component({
  selector: "my-button",
  templateUrl: "./my-button.component.html",
})
export class MyButtonComponent {
  @Input() type: "primary" | "secondary" | "text" = "text";
}
Enter fullscreen mode Exit fullscreen mode
<!-- my-button.component.html -->
<ng-container *ngIf="type === 'primary'">
  <button mat-flat-button color="primary">
    <ng-content></ng-content>
  </button>
</ng-container>

<ng-container *ngIf="type === 'secondary'">
  <button mat-stroked-button color="primary">
    <ng-content></ng-content>
  </button>
</ng-container>

<ng-container *ngIf="type === 'text'">
  <button mat-button color="primary">
    <ng-content></ng-content>
  </button>
</ng-container>
Enter fullscreen mode Exit fullscreen mode

At first glance, this might look acceptable. Since directives cannot be added conditionally in Angular, ngIf is used to select the button style based on the type value.

Let’s try it out. (StackBlitz)

Demo to solution 1: Content projection for “primary” and “secondary” buttons is broken

Something is clearly off.

The root cause is how <ng-content> handles conditional rendering. The "Conditional content projection" documentation states that “[...] when the consumer of a component supplies the content, that content is always initialized, even if the component does not define an <ng-content> element or if that <ng-content> element is inside of an ngIf statement”.

So, relying on a single <ng-content> slot when a condition determines where the content lands doesn’t work as expected. The documentation suggests using <ng-template> in such cases.

This means the first attempt won’t satisfy the goals, so we need another path.

Second attempt

Following the framework’s recommendation, this version uses <ng-template>. The template changes slightly, while the component class remains untouched.

<!-- my-button.component.html -->
<ng-container *ngIf="type === 'primary'">
  <button mat-flat-button color="primary">
    <ng-container [ngTemplateOutlet]="buttonContent"></ng-container>
  </button>
</ng-container>

<ng-container *ngIf="type === 'secondary'">
  <button mat-stroked-button color="primary">
    <ng-container [ngTemplateOutlet]="buttonContent"></ng-container>
  </button>
</ng-container>

<ng-container *ngIf="type === 'text'">
  <button mat-button color="primary">
    <ng-container [ngTemplateOutlet]="buttonContent"></ng-container>
  </button>
</ng-container>

<ng-template #buttonContent>
  <ng-content></ng-content>
</ng-template>
Enter fullscreen mode Exit fullscreen mode

A few fresh elements appear here. The <ng-content> for each button now sits inside an ng-template block. Second, a template variable #buttonContent captures the reference to that ng-template. Finally, ngTemplateOutlet inside each button renders the referenced <ng-template>.

Let’s see it in action. (StackBlitz)

Demo to solution 2: All buttons are displayed correctly

That works. Angular doesn’t initialize the content of an <ng-template> until the element is explicitly rendered, which is why this pattern functions properly.

Is the work complete? Should we send a pull request (PR) and wrap up?

Before doing that, let’s reflect on a few questions:

  1. What drawbacks exist in this approach?
  2. Could it handle future needs, like extra button styles, icons, disabled or loading states, or acting as a link?

One downside: the template grows as more button styles are needed. If the component also needs to work for links, the template could get unwieldy—especially since a “button” and a “link” often belong in one component. Another weakness is the redundant logic—a heap of ngIfs—that is better placed in the component class to keep the template lean.

Flexibility for new requirements is a genuine concern. New use cases will appear, and the component might need to accommodate them. Is it truly designed for that?

Some might stop here and say it’s sufficient, since it meets the current goals. Others would prefer to see alternative solutions and learn how to make the component more adaptable. That’s what we’ll dive into in part 2.

Thanks to Lars Gyrup Brink Nielsen for reviewing this post.


Photo by Chris Lawton on Unsplash