Build Reusable Angular Components: Inputs, Outputs, Content Projection, and Standalone Components
Angular apps get messy fast when UI logic is copied across pages: duplicated markup, slightly different styles, and “just one more tweak” for a specific screen. Reusable components are how you stop that drift. In this hands-on guide, you’ll build a small set of practical UI building blocks using modern Angular patterns: @Input(), @Output(), content projection (<ng-content>), and standalone components.
You’ll end up with:
- A
ButtonComponentwith variants, loading state, and disabled logic - An
AlertComponentthat supports projected content and optional actions - A
ModalComponentwith two-way-ish open state via@Output() - A clean usage pattern in a page component
1) Start with Standalone Components (No NgModule Needed)
Standalone components let you import dependencies directly in the component, reducing module boilerplate. You can generate one via CLI (optional):
ng generate component ui/button --standalone
Or create it manually. Below is a simple, reusable button.
2) A Practical Button Component (Inputs Done Right)
Buttons often need consistent styling, variants (primary/secondary/danger), and loading states. We’ll accept configuration via @Input() and expose a click event via @Output().
// src/app/ui/button/button.component.ts import { Component, EventEmitter, Input, Output } from '@angular/core'; import { NgClass } from '@angular/common'; type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost'; @Component({ selector: 'app-button', standalone: true, imports: [NgClass], template: ` <button type="button" class="btn" [ngClass]="variantClass" [disabled]="disabled || loading" (click)="handleClick($event)" > <span *ngIf="loading" class="spinner" aria-hidden="true"></span> <span class="label"> <ng-content></ng-content> </span> </button> `, styles: [` .btn { padding: 0.6rem 1rem; border-radius: 10px; border: 1px solid transparent; cursor: pointer; display: inline-flex; gap: 0.5rem; align-items: center; } .btn:disabled { opacity: 0.6; cursor: not-allowed; } .primary { background: #2563eb; color: white; } .secondary { background: #e5e7eb; color: #111827; } .danger { background: #dc2626; color: white; } .ghost { background: transparent; border-color: #e5e7eb; color: #111827; } .spinner { width: 14px; height: 14px; border-radius: 50%; border: 2px solid rgba(255,255,255,0.6); border-top-color: rgba(255,255,255,1); animation: spin 0.8s linear infinite; } .secondary .spinner, .ghost .spinner { border-color: rgba(17,24,39,0.25); border-top-color: rgba(17,24,39,0.9); } @keyframes spin { to { transform: rotate(360deg); } } `] }) export class ButtonComponent { @Input() variant: ButtonVariant = 'primary'; @Input() loading = false; @Input() disabled = false; // Prefer output names that read like events @Output() pressed = new EventEmitter<MouseEvent>(); get variantClass(): string { return this.variant; } handleClick(event: MouseEvent) { if (this.disabled || this.loading) return; this.pressed.emit(event); } }
Why this is “practical”:
disabled || loadingprevents double submits<ng-content>lets you put any label (including icons) inside@Output() pressedgives parents a clean event to hook into
3) Alert Component with Content Projection
Alerts are a great use-case for content projection: you want flexible message markup, and sometimes an action button slot.
// src/app/ui/alert/alert.component.ts import { Component, Input } from '@angular/core'; import { NgClass, NgIf } from '@angular/common'; type AlertType = 'info' | 'success' | 'warning' | 'error'; @Component({ selector: 'app-alert', standalone: true, imports: [NgClass, NgIf], template: ` <div class="alert" [ngClass]="type"> <div class="content"> <div class="title" *ngIf="title">{{ title }}</div> <div class="message"> <ng-content></ng-content> </div> </div> <div class="actions"> <ng-content select="[alertActions]"></ng-content> </div> </div> `, styles: [` .alert { padding: 1rem; border-radius: 12px; display: flex; justify-content: space-between; gap: 1rem; border: 1px solid; } .title { font-weight: 700; margin-bottom: 0.25rem; } .actions { display: flex; align-items: start; } .info { background: #eff6ff; border-color: #bfdbfe; color: #1e3a8a; } .success { background: #ecfdf5; border-color: #bbf7d0; color: #065f46; } .warning { background: #fffbeb; border-color: #fde68a; color: #92400e; } .error { background: #fef2f2; border-color: #fecaca; color: #991b1b; } `] }) export class AlertComponent { @Input() type: AlertType = 'info'; @Input() title?: string; }
This uses two projection areas:
- Default slot for message body
- A named slot using
select="[alertActions]"for optional actions
4) Modal Component with Open/Close Events
Modals are tricky because they have state. A clean pattern is: parent owns the open state; modal emits closed (or openChange) events.
// src/app/ui/modal/modal.component.ts import { Component, EventEmitter, Input, Output } from '@angular/core'; import { NgIf } from '@angular/common'; @Component({ selector: 'app-modal', standalone: true, imports: [NgIf], template: ` <div *ngIf="open" class="backdrop" (click)="requestClose()"> <div class="panel" (click)="$event.stopPropagation()" role="dialog" aria-modal="true"> <div class="header"> <div class="title">{{ title }}</div> <button class="x" type="button" (click)="requestClose()" aria-label="Close">×</button> </div> <div class="body"> <ng-content></ng-content> </div> <div class="footer"> <ng-content select="[modalFooter]"></ng-content> </div> </div> </div> `, styles: [` .backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; padding: 1rem; } .panel { width: min(560px, 100%); background: white; border-radius: 16px; box-shadow: 0 10px 30px rgba(0,0,0,0.2); overflow: hidden; } .header { display: flex; justify-content: space-between; align-items: center; padding: 1rem 1rem 0.75rem; border-bottom: 1px solid #e5e7eb; } .title { font-weight: 700; } .x { font-size: 1.25rem; border: none; background: transparent; cursor: pointer; padding: 0.25rem 0.5rem; } .body { padding: 1rem; } .footer { padding: 0.75rem 1rem 1rem; border-top: 1px solid #e5e7eb; display: flex; justify-content: flex-end; gap: 0.5rem; } `] }) export class ModalComponent { @Input() open = false; @Input() title = 'Modal'; // Parent decides what "close" means; we only request it. @Output() openChange = new EventEmitter<boolean>(); requestClose() { this.openChange.emit(false); } }
Notice the convention: open + openChange. That makes it compatible with Angular’s “banana-in-a-box” syntax:
[(open)]="isModalOpen"
5) Use Them Together in a Page (Hands-On Example)
Here’s a standalone page component that wires everything together. It shows how to handle loading states, projected actions, and modal state.
// src/app/pages/demo/demo.component.ts import { Component } from '@angular/core'; import { ButtonComponent } from '../../ui/button/button.component'; import { AlertComponent } from '../../ui/alert/alert.component'; import { ModalComponent } from '../../ui/modal/modal.component'; import { NgIf } from '@angular/common'; @Component({ selector: 'app-demo', standalone: true, imports: [NgIf, ButtonComponent, AlertComponent, ModalComponent], template: ` <app-alert type="info" title="Tip"> Use reusable components to keep your UI consistent and easier to maintain. <div alertActions> <app-button variant="ghost" (pressed)="isModalOpen = true">Learn more</app-button> </div> </app-alert> <p style="margin-top: 1rem;"> <app-button variant="primary" [loading]="saving" (pressed)="save()" > Save changes </app-button> <app-button variant="secondary" [disabled]="saving" (pressed)="reset()" style="margin-left: 0.5rem;" > Reset </app-button> </p> <app-modal [(open)]="isModalOpen" title="About this UI kit"> <p> This modal uses <code>open</code> + <code>openChange</code> so the parent controls state. Click outside or the × button to close. </p> <div modalFooter> <app-button variant="ghost" (pressed)="isModalOpen = false">Close</app-button> </div> </app-modal> <app-alert *ngIf="toast" [type]="toast.type" [title]="toast.title" style="margin-top: 1rem;"> {{ toast.message }} </app-alert> ` }) export class DemoComponent { saving = false; isModalOpen = false; toast: null | { type: 'success' | 'error'; title: string; message: string } = null; async save() { this.saving = true; this.toast = null; try { // Simulate a request await new Promise((r) => setTimeout(r, 900)); this.toast = { type: 'success', title: 'Saved', message: 'Your changes have been saved.' }; } catch { this.toast = { type: 'error', title: 'Error', message: 'Something went wrong.' }; } finally { this.saving = false; } } reset() { this.toast = { type: 'success', title: 'Reset', message: 'Form reset (demo).' }; } }
6) Common Gotchas and Best Practices
-
Don’t mutate inputs unexpectedly. Treat
@Input()values as read-only inside the child. If you need internal state, store it separately. -
Name outputs like events. Prefer
closed,saved,pressedover genericchange. For two-way binding, use the patternvalue+valueChange. -
Use content projection for flexibility, not everything. If your component always has the same structure, inputs might be enough. Projection shines when the content can vary (rich text, multiple elements,
Leave a Reply