Building Reusable Angular Components: Inputs/Outputs, Content Projection, and a Custom Form Control
Reusable components are where Angular really pays off—but only if you design them so they’re easy to drop into multiple screens without copy/paste tweaks. In this hands-on guide, you’ll build three “real app” components:
- A flexible Button with variants, loading state, and disabled handling
- A Modal using content projection (
<ng-content>) so you can inject any HTML - A custom form control (
ControlValueAccessor) so your component works with Angular forms like a native input
The goal: patterns you can reuse across projects, not just one-off components.
1) A Practical Button Component (Inputs + Accessibility + Loading)
You want one button that can be used everywhere: primary/secondary styles, icons, loading spinners, and proper disabled behavior. The key is to model behavior as @Input()s and keep the template predictable.
button.component.ts
import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
type ButtonVariant = 'primary' | 'secondary' | 'danger';
type ButtonSize = 'sm' | 'md' | 'lg';
@Component({
selector: 'app-button',
templateUrl: './button.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ButtonComponent {
@Input() variant: ButtonVariant = 'primary';
@Input() size: ButtonSize = 'md';
@Input() type: 'button' | 'submit' = 'button';
@Input() loading = false;
@Input() disabled = false;
// Optional: allow passing an aria-label if the button only contains an icon
@Input() ariaLabel?: string;
get isDisabled(): boolean {
return this.disabled || this.loading;
}
get classes(): string {
const base = 'btn';
return [
base,
${base}--${this.variant},
${base}--${this.size},
this.loading ? ${base}--loading : '',
].filter(Boolean).join(' ');
}
}
button.component.html
<button [attr.type]="type" [class]="classes" [disabled]="isDisabled" [attr.aria-label]="ariaLabel || null" > <span class="btn__spinner" *ngIf="loading" aria-hidden="true"></span>
Usage
<app-button variant="primary" (click)="save()" [loading]="saving"> Save changes </app-button>
Delete
- Why
isDisabled? Loading buttons should block double-submits. - Why
ariaLabel? Icon-only buttons need accessible labels. - Why
OnPush? Most UI components are input-driven;OnPushkeeps them fast.
2) A Modal Component with Content Projection (Header/Body/Footer Slots)
Modals become painful when every screen needs a slightly different layout. Content projection lets you define a stable shell and “slot” in content from the parent.
modal.component.ts
import { Component, EventEmitter, Input, Output, HostListener } from '@angular/core';
@Component({
selector: 'app-modal',
templateUrl: './modal.component.html',
})
export class ModalComponent {
@Input() open = false;
@Input() title = '';
@Input() closeOnBackdrop = true;
@Output() openChange = new EventEmitter();
@Output() closed = new EventEmitter();
close(): void {
this.open = false;
this.openChange.emit(false);
this.closed.emit();
}
backdropClick(): void {
if (this.closeOnBackdrop) this.close();
}
@HostListener('document:keydown.escape')
onEscape(): void {
if (this.open) this.close();
}
}
modal.component.html
<div class="modal-backdrop" *ngIf="open" (click)="backdropClick()"> <div class="modal" role="dialog" aria-modal="true" (click)="$event.stopPropagation()">
<div class="modal__header">
<h3 class="modal__title">{{ title }}</h3>
<button class="modal__close" type="button" (click)="close()" aria-label="Close">×</button>
</div>
<div class="modal__body">
<ng-content select="[modalBody]"></ng-content>
</div>
<div class="modal__footer">
<ng-content select="[modalFooter]"></ng-content>
</div>
Leave a Reply