Building Reusable Angular Components: Inputs/Outputs, Content Projection, and a Custom Form Control

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; OnPush keeps 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>

Usage

<app-modal [(open)]="confirmOpen" title="Confirm delete">

This action cannot be undone. Continue?

Cancel Delete
Open modal

Two important patterns here:

  • Two-way binding for open state with [(open)] via @Output() openChange.
  • Named slots using select="[modalBody]" and select="[modalFooter]" so the parent can provide structured content without the modal knowing anything about it.

3) A Custom Form Control with ControlValueAccessor (Works with Reactive Forms)

This is where many teams get stuck: building a “nice” input component that doesn’t integrate cleanly with forms. If you implement ControlValueAccessor, your component can participate in validation, touched/dirty state, and form submission like a native <input>.

Example: a Debounced Search Input that emits updates and supports formControlName.

search-input.component.ts

import { Component, forwardRef, Input, OnDestroy } from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
@Component({
selector: 'app-search-input',
templateUrl: './search-input.component.html',
providers: [{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => SearchInputComponent),
multi: true,
}]
})
export class SearchInputComponent implements ControlValueAccessor, OnDestroy {
@Input() placeholder = 'Search...';
@Input() debounceMs = 250;
value = '';
disabled = false;
private onChange: (v: string) => void = () => {};
private onTouched: () => void = () => {};
private timer?: number;
writeValue(v: string | null): void {
this.value = v ?? '';
}
registerOnChange(fn: (v: string) => void): void {
this.onChange = fn;
}
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
}
onInput(v: string): void {
this.value = v;
// Debounce updates to the form control
if (this.timer) window.clearTimeout(this.timer);
this.timer = window.setTimeout(() => this.onChange(this.value), this.debounceMs);
}
onBlur(): void {
this.onTouched();
}
clear(): void {
this.onInput('');
// Optionally update immediately on clear:
if (this.timer) window.clearTimeout(this.timer);
this.onChange('');
this.onTouched();
}
ngOnDestroy(): void {
if (this.timer) window.clearTimeout(this.timer);
}
}

search-input.component.html

<div class="search"> <input class="search__input" type="search" [placeholder]="placeholder" [value]="value" [disabled]="disabled" (input)="onInput(($event.target as HTMLInputElement).value)" (blur)="onBlur()" />

Use it with Reactive Forms

import { Component } from '@angular/core'; import { FormControl, FormGroup } from '@angular/forms';
@Component({
selector: 'app-users-page',
template: `
<ul> <li *ngFor="let u of filteredUsers">{{ u }}</li> </ul> ` }) export class UsersPageComponent { form = new FormGroup({ q: new FormControl(''), }); users = ['Alice', 'Bob', 'Charlie', 'David', 'Eve']; filteredUsers = this.users; constructor() { this.form.controls.q.valueChanges.subscribe(q => { const query = (q ?? '').toLowerCase().trim(); this.filteredUsers = this.users.filter(u => u.toLowerCase().includes(query)); }); } }

Now your component supports:


Component Design Checklist (What to Do in Real Projects)


Wrap-up

If you can build a Button, a Modal shell with slots, and a ControlValueAccessor input, you can build most of the component library your app needs—without pulling in a heavy UI framework. Start small, standardize patterns, and your future self (and teammates) will thank you when features ship faster and screens stay consistent.


Leave a Reply

Your email address will not be published. Required fields are marked *