Building Reusable Angular Components: Standalone + Inputs/Outputs + Content Projection (Hands-On)

Building Reusable Angular Components: Standalone + Inputs/Outputs + Content Projection (Hands-On)

Reusable UI components are one of Angular’s superpowers—if you design them with clear APIs and predictable behavior. In this hands-on guide, you’ll build a small “design-system style” component set: a <app-button> with variants and loading state, and a <app-modal> with content projection and an event-driven close mechanism.

The examples use standalone components (no NgModules) because they simplify reuse and are now the default approach in many Angular projects.

1) The goal: components with a clean API

When you make a component, think like a library author. Your component should:

  • Expose a small set of @Input() properties for configuration.
  • Emit meaningful events via @Output() (not “everything happened” events).
  • Support content projection (<ng-content>) when the caller should control markup.
  • Be accessible by default (keyboard + ARIA).
  • Be easy to test.

2) Create a reusable Button component

This button supports variants, sizes, and a loading state. It also prevents double-submits by disabling itself while loading.

// app-button.component.ts import { Component, Input } from '@angular/core'; type ButtonVariant = 'primary' | 'secondary' | 'danger'; type ButtonSize = 'sm' | 'md' | 'lg'; @Component({ selector: 'app-button', standalone: true, template: ` <button type="button" class="btn" [class.btn-primary]="variant === 'primary'" [class.btn-secondary]="variant === 'secondary'" [class.btn-danger]="variant === 'danger'" [class.btn-sm]="size === 'sm'" [class.btn-md]="size === 'md'" [class.btn-lg]="size === 'lg'" [disabled]="disabled || loading" [attr.aria-busy]="loading ? 'true' : null" > <span class="spinner" *ngIf="loading" aria-hidden="true"></span> <span class="label"><ng-content></ng-content></span> </button> `, styles: [` .btn { display:inline-flex; align-items:center; gap:.5rem; border-radius:.5rem; border:1px solid transparent; padding:.55rem .9rem; cursor:pointer; font-weight:600; } .btn:disabled { opacity:.6; cursor:not-allowed; } .btn-primary { background:#2563eb; color:white; } .btn-secondary { background:#e5e7eb; color:#111827; } .btn-danger { background:#dc2626; color:white; } .btn-sm { font-size:.85rem; padding:.4rem .7rem; } .btn-md { font-size:1rem; } .btn-lg { font-size:1.05rem; padding:.7rem 1.05rem; } .spinner { width:14px; height:14px; border-radius:999px; border:2px solid rgba(255,255,255,.5); border-top-color:white; animation: spin .8s linear infinite; } .btn-secondary .spinner { border-color: rgba(17,24,39,.25); border-top-color:#111827; } @keyframes spin { to { transform: rotate(360deg); } } `] }) export class AppButtonComponent { @Input() variant: ButtonVariant = 'primary'; @Input() size: ButtonSize = 'md'; @Input() loading = false; @Input() disabled = false; } 

Usage in any template:

// any.component.ts import { Component } from '@angular/core'; import { AppButtonComponent } from './app-button.component'; @Component({ selector: 'app-demo-buttons', standalone: true, imports: [AppButtonComponent], template: ` <app-button variant="primary">Save</app-button> <app-button variant="secondary">Cancel</app-button> <app-button variant="danger" [loading]="isDeleting">Delete</app-button> ` }) export class DemoButtonsComponent { isDeleting = false; } 

Notice how the “API surface” stays small: variant, size, loading, disabled. The caller provides the label via <ng-content>.

3) Build a Modal with content projection and close events

Modals are a great example of when to combine:

  • @Input() to control visibility
  • @Output() to request state changes
  • content projection for header/body/footer
  • basic accessibility: role, aria attributes, escape key
// app-modal.component.ts import { Component, EventEmitter, HostListener, Input, Output } from '@angular/core'; @Component({ selector: 'app-modal', standalone: true, template: ` <div class="backdrop" *ngIf="open" (click)="onBackdropClick()"></div> <div class="modal" *ngIf="open" role="dialog" aria-modal="true" [attr.aria-label]="ariaLabel" > <div class="header"> <ng-content select="[modal-title]"></ng-content> <button class="icon-btn" type="button" (click)="requestClose()" aria-label="Close dialog"> ✕ </button> </div> <div class="body"> <ng-content select="[modal-body]"></ng-content> </div> <div class="footer"> <ng-content select="[modal-footer]"></ng-content> </div> </div> `, styles: [` .backdrop { position:fixed; inset:0; background:rgba(0,0,0,.45); } .modal { position:fixed; inset:0; margin:auto; width:min(560px, calc(100% - 2rem)); height:fit-content; background:white; border-radius:1rem; padding:1rem; box-shadow: 0 10px 30px rgba(0,0,0,.25); } .header { display:flex; align-items:center; justify-content:space-between; gap:1rem; } .body { margin-top:.75rem; color:#111827; } .footer { margin-top:1rem; display:flex; justify-content:flex-end; gap:.5rem; } .icon-btn { border:none; background:transparent; font-size:1.25rem; cursor:pointer; padding:.25rem .5rem; } `] }) export class AppModalComponent { @Input() open = false; @Input() ariaLabel = 'Dialog'; @Output() close = new EventEmitter(); requestClose() { this.close.emit(); } onBackdropClick() { this.requestClose(); } @HostListener('document:keydown.escape') onEsc() { if (this.open) this.requestClose(); } } 

Usage: the parent owns the open/close state, and the modal emits close when it wants to be dismissed.

// demo-modal.component.ts import { Component } from '@angular/core'; import { AppModalComponent } from './app-modal.component'; import { AppButtonComponent } from './app-button.component'; @Component({ selector: 'app-demo-modal', standalone: true, imports: [AppModalComponent, AppButtonComponent], template: ` <app-button (click)="open = true">Open modal</app-button> <app-modal [open]="open" ariaLabel="Confirm action" (close)="open = false"> <h3 modal-title style="margin:0">Confirm deletion</h3> <div modal-body> <p>This will permanently delete the record. Are you sure?</p> </div> <div modal-footer> <app-button variant="secondary" (click)="open = false">Cancel</app-button> <app-button variant="danger" [loading]="isDeleting" (click)="delete()">Delete</app-button> </div> </app-modal> ` }) export class DemoModalComponent { open = false; isDeleting = false; async delete() { this.isDeleting = true; try { // Simulate API call await new Promise(r => setTimeout(r, 800)); this.open = false; } finally { this.isDeleting = false; } } } 

4) A practical pattern: “controlled components”

Notice the modal doesn’t mutate its own open state—it asks the parent to close. This “controlled component” pattern makes your UI easier to reason about because:

  • The parent is the single source of truth.
  • You avoid state desync (“modal thinks it’s open but parent thinks it’s closed”).
  • Testing becomes straightforward.

If you want a two-way binding style, you can add a matching output (e.g., openChange) and use Angular’s banana-in-a-box syntax, but the explicit (close) event is often clearer for modals.

5) Add quick unit tests (so refactors don’t break behavior)

Here’s a simple Jasmine/Karma-style test for the modal to verify it emits close on escape and backdrop click. (The exact setup depends on your Angular test runner, but the core idea stays the same.)

// app-modal.component.spec.ts import { ComponentFixture, TestBed } from '@angular/core/testing'; import { AppModalComponent } from './app-modal.component'; describe('AppModalComponent', () => { let fixture: ComponentFixture<AppModalComponent>; let component: AppModalComponent; beforeEach(async () => { await TestBed.configureTestingModule({ imports: [AppModalComponent], }).compileComponents(); fixture = TestBed.createComponent(AppModalComponent); component = fixture.componentInstance; }); it('emits close when escape is pressed and open=true', () => { component.open = true; fixture.detectChanges(); const spy = spyOn(component.close, 'emit'); document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); expect(spy).toHaveBeenCalled(); }); it('emits close when backdrop is clicked', () => { component.open = true; fixture.detectChanges(); const spy = spyOn(component.close, 'emit'); const backdrop = fixture.nativeElement.querySelector('.backdrop'); backdrop.click(); expect(spy).toHaveBeenCalled(); }); }); 

6) Common component mistakes (and how to avoid them)

  • Overloading inputs: Avoid “magic” inputs like config objects that contain everything. Prefer explicit @Input() fields (variant, size, loading).

  • Emitting vague events: Instead of changed, emit close, confirmed, saved—events that describe intent.

  • Hardcoding markup: When callers should control layout, use <ng-content> and selective slots (select="[modal-footer]").

  • Ignoring accessibility: Add role="dialog", aria-modal, sensible labels, and escape-key behavior at a minimum.

7) Where to take this next

With these two components in place, you can incrementally turn them into a small internal component library:

  • Add a type input to <app-button> (e.g., submit), and support icons via projected content.
  • Improve modal focus management (focus trap, return focus on close).
  • Extract shared styles into a design tokens file (CSS variables) for consistent spacing and colors.
  • Document component APIs with short examples right next to the code (or in Storybook if you use it).

Reusable components aren’t about fancy abstractions—they’re about stable, boring APIs that your team can use everywhere with confidence. Start small, keep the surface area tight, and add features only when you have a real use case.


Leave a Reply

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