Skip to main content

Command Palette

Search for a command to run...

Angular 21+ Reusable Button Component – Complete Usage Guide with Signals, Variants & Loading States

Updated
6 min read
S

Technology Enthusiast and voracious reader with a demonstrated history of working in the computer software industry. Skilled in PHP, JavaScript, NodeJS, Angular, MySQL, MongoDB, Web3, Product Development, Project Management, and Teamwork.

A detailed usage guide for a global reusable button component built with Angular 21+, Signals API, and Tailwind CSS. Learn how to control variants, sizes, loading states, accessibility, and dynamic behavior from parent components with real-world examples.

submit-button.html

<button [type]="type()" [disabled]="isDisabled" [class]="buttonClasses" (click)="onClick()" [attr.aria-busy]="loading()"
    [attr.aria-disabled]="isDisabled">
    @if (loading()) {
    <svg class="w-5 h-5 mr-2 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
        aria-hidden="true">
        <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
        <path class="opacity-75" fill="currentColor"
            d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
        </path>
    </svg>
    <span>{{ displayText }}</span>
    } @else {
    <ng-content>{{ text() }}</ng-content>
    }
</button>

submit-button.ts

import { Component, ChangeDetectionStrategy, input, output } from '@angular/core';

export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'danger';
export type ButtonSize = 'sm' | 'md' | 'lg';
export type ButtonType = 'button' | 'submit' | 'reset';

@Component({
    selector: 'app-submit-button',
    templateUrl: './submit-button.html',
    styleUrl: './submit-button.css',
    changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SubmitButton {
    /** Button text - can also use content projection */
    text = input<string>('Submit');

    /** Loading state - shows spinner and disables button */
    loading = input<boolean>(false);

    /** Disabled state */
    disabled = input<boolean>(false);

    /** Button type */
    type = input<ButtonType>('submit');

    /** Button variant for styling */
    variant = input<ButtonVariant>('primary');

    /** Button size */
    size = input<ButtonSize>('md');

    /** Full width button */
    fullWidth = input<boolean>(true);

    /** Loading text - shown when loading */
    loadingText = input<string>('');

    /** Click event - only emitted when not loading/disabled */
    clicked = output<void>();

    protected get buttonClasses(): string {
        const baseClasses = 'inline-flex items-center justify-center font-medium rounded-lg focus:ring-4 focus:outline-none transition-colors duration-200';

        const sizeClasses: Record<ButtonSize, string> = {
            sm: 'px-3 py-2 text-xs',
            md: 'px-5 py-2.5 text-sm',
            lg: 'px-6 py-3 text-base',
        };

        const variantClasses: Record<ButtonVariant, string> = {
            primary: 'text-white bg-primary-600 hover:bg-primary-700 focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800',
            secondary: 'text-white bg-secondary-600 hover:bg-secondary-700 focus:ring-secondary-300 dark:bg-secondary-600 dark:hover:bg-secondary-700 dark:focus:ring-secondary-800',
            outline: 'text-gray-900 bg-white border border-gray-300 hover:bg-gray-100 focus:ring-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700',
            danger: 'text-white bg-red-600 hover:bg-red-700 focus:ring-red-300 dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-800',
        };

        const widthClass = this.fullWidth() ? 'w-full' : '';
        const disabledClass = (this.loading() || this.disabled()) ? 'opacity-70 cursor-not-allowed' : '';

        return `${baseClasses} ${sizeClasses[this.size()]} ${variantClasses[this.variant()]} ${widthClass} ${disabledClass}`.trim();
    }

    protected get isDisabled(): boolean {
        return this.loading() || this.disabled();
    }

    protected get displayText(): string {
        if (this.loading() && this.loadingText()) {
            return this.loadingText();
        }
        return this.text();
    }

    protected onClick(): void {
        if (!this.isDisabled) {
            this.clicked.emit();
        }
    }
}

Usage Guide

Overview

SubmitButton is a reusable, signal-based Angular button component designed for forms and general actions.
It supports:

  • Variant-based styling (primary, secondary, outline, danger)

  • Size control

  • Loading and disabled states

  • Full-width or inline layout

  • Accessibility (ARIA attributes)

  • Controlled click emission (blocked during loading/disabled)

This component is suitable for form submissions, API actions, and workflow triggers across the application.


Component Selector

<app-submit-button></app-submit-button>

Public API (Inputs & Output)

Inputs (Signals)

All inputs are signal-based, meaning values can be static or dynamically bound from the parent component.

Input NameTypeDefaultPurpose
textstring'Submit'Button label (fallback when no content projection)
loadingbooleanfalseShows spinner, disables button
disabledbooleanfalseDisables the button
type`'button''submit''reset'`'submit'Native HTML button type
variant`'primary''secondary''outline''danger'`'primary'Visual style
size`'sm''md''lg'`'md'Button size
fullWidthbooleantrueControls width (w-full)
loadingTextstring''Text displayed during loading

Output

Output NameTypeWhen Emitted
clickedEventEmitter<void>Only when loading === false AND disabled === false

Basic Usage Example

Simple Submit Button

<app-submit-button></app-submit-button>

Result

  • Text: Submit

  • Variant: Primary

  • Size: Medium

  • Full width

  • Type: submit


Passing Dynamic Data from Parent Component

Parent Component (TypeScript)

export class LoginComponent {
  isSubmitting = false;

  onSubmit() {
    this.isSubmitting = true;

    setTimeout(() => {
      this.isSubmitting = false;
    }, 2000);
  }
}

Template (HTML)

<app-submit-button
  text="Login"
  [loading]="isSubmitting"
  loadingText="Logging in..."
  (clicked)="onSubmit()">
</app-submit-button>

What Is Controlled Dynamically?

PropertyControlled By Parent
Button labeltext
Spinner visibilityloading
Disabled stateAuto (via loading)
Click action(clicked)

Content Projection (Custom Button Text)

You can override the text input using content projection.

<app-submit-button variant="secondary">
  Save Changes
</app-submit-button>

Rule

  • If loading === true → projected content is hidden

  • If loading === false → projected content is shown


Variants (Styling Control)

<app-submit-button variant="primary">Primary</app-submit-button>
<app-submit-button variant="secondary">Secondary</app-submit-button>
<app-submit-button variant="outline">Outline</app-submit-button>
<app-submit-button variant="danger">Delete</app-submit-button>

Use Case Guidance

VariantRecommended Usage
primaryMain call-to-action
secondarySecondary actions
outlineLow-priority actions
dangerDestructive actions

Size Control

<app-submit-button size="sm">Small</app-submit-button>
<app-submit-button size="md">Medium</app-submit-button>
<app-submit-button size="lg">Large</app-submit-button>

Full Width vs Inline Button

Full Width (Default)

<app-submit-button>
  Submit
</app-submit-button>

Inline Button

<app-submit-button [fullWidth]="false">
  Cancel
</app-submit-button>

Disabled State Control

Manual Disable

<app-submit-button
  text="Save"
  [disabled]="true">
</app-submit-button>
<app-submit-button
  text="Save"
  [disabled]="!form.valid"
  [loading]="isSaving"
  (clicked)="save()">
</app-submit-button>

Internal Behavior

  • disabled === true → button disabled

  • loading === true → button disabled

  • Click is not emitted in both cases


Button Type (Forms)

Submit Button (Default)

<form (ngSubmit)="submit()">
  <app-submit-button>
    Submit Form
  </app-submit-button>
</form>

Reset Button

<app-submit-button type="reset" variant="outline">
  Reset
</app-submit-button>

Normal Button

<app-submit-button
  type="button"
  (clicked)="openModal()">
  Open Modal
</app-submit-button>

Loading State UX

<app-submit-button
  text="Create Account"
  loadingText="Creating..."
  [loading]="isCreating"
  (clicked)="createAccount()">
</app-submit-button>

Behavior

  • Spinner shown

  • Text replaced by loadingText

  • Click blocked

  • aria-busy="true" applied


Accessibility Features

Built-in accessibility support:

  • aria-busy → reflects loading state

  • aria-disabled → reflects disabled state

  • Button is natively disabled (disabled attribute)

  • Spinner marked aria-hidden="true"

No additional ARIA configuration required from parent.


What Parent Can Control vs Internal Logic

Controlled by Parent Component

  • Button label (text or projected content)

  • Loading state (loading)

  • Disabled logic (disabled)

  • Visual style (variant, size)

  • Layout (fullWidth)

  • Button behavior (type)

  • Action handling (clicked)

Controlled Internally by Button

  • Spinner visibility

  • Preventing double clicks

  • Disabled logic during loading

  • CSS class composition

  • Accessibility attributes


  1. Always bind loading to API calls

  2. Avoid handling (click) directly – use (clicked)

  3. Use danger variant only for destructive actions

  4. Prefer content projection for dynamic labels

  5. Keep business logic in parent components


Summary

This button component acts as a centralized UI control point ensuring:

  • Consistent UX

  • Safe click handling

  • Accessible behavior

  • Tailwind-based theming

  • Angular 21+ signal best practices

It is production-ready and well-suited for use as a design-system standard button across your Angular applications.

More from this blog

V

Voice of Dev

17 posts