Skip to Content
ComponentsAngular

Angular Components

TailAdmin Angular offers a curated set of customizable, accessible UI components designed to accelerate dashboard development. Built with Angular 22 (standalone components), Tailwind CSS v4, Angular CDK  and Spartan Brain , each component combines clean design with robust accessibility.

UI Primitives & Accessibility

TailAdmin Angular keeps its UI primitives in src/app/shared/components/ui/. Interactive primitives are built on headless foundations: Angular CDK (@angular/cdk/menu, @angular/cdk/overlay) for menus and overlays, and Spartan Brain (@spartan-ng/brain) for behaviour such as the button’s disabled state.

Using these foundations ensures comprehensive accessibility management across your application:

  • WAI-ARIA Compliance: Built-in support for ARIA roles, attributes, and screen reader announcements.
  • Keyboard Navigation & Focus Management: Native keyboard interactions (such as Escape to dismiss, Arrow keys for menu traversal, and typeahead in menus).
  • Headless Flexibility: Behaviour comes from CDK and Brain, while all styling is Tailwind CSS v4 classes on the host element, so there are no style conflicts or extra runtime bloat.
  • State-driven Attributes: Primitives expose declarative data attributes (data-slot, data-disabled, data-highlighted, data-popup-open) for seamless animation and styling.

For the complete API reference of the underlying primitives, refer to the Angular CDK Menu  and Spartan Brain  documentation.

Usage Instructions

Primitives are standalone Angular components and directives. Import the ones you need into the imports array of your component. Primitives with several parts export an array constant (such as DROPDOWN_MENU_COMPONENTS, TABLE_IMPORTS and FIELD_IMPORTS) that you can spread into imports.

Static Component

For static elements without internal state (such as Buttons, Badges, or Avatars), import the primitive and pass your desired variant, size, or Tailwind classes:

import { Component } from "@angular/core"; import { ButtonComponent } from "<path-to>/shared/components/ui/button/button.component"; @Component({ selector: "app-button-example", imports: [ButtonComponent], template: ` <div class="flex flex-wrap items-center gap-3"> <button app-button variant="default" size="sm">Primary Action</button> <button app-button variant="outline" size="sm">Secondary Outline</button> <button app-button variant="ghost" size="sm">Ghost</button> <button app-button variant="destructive" size="sm">Delete</button> </div> `, }) export class ButtonExampleComponent {}

app-button works on <button> and <a> elements. Available variants are default, outline-solid, ghost, destructive, link and unstyled (no built-in look, only the classes you pass).

Interactive Components

Interactive elements (such as Dropdowns, Modals, Popovers, Tabs, and Selects) use compound components that coordinate state and accessibility attributes automatically.

The dropdown primitive uses the Angular CDK Menu  under the hood, managing trigger interactions, keyboard navigation, and overlay positioning. Put the menu in an ng-template and attach it to a trigger with appDropdownMenuTrigger:

import { Component } from "@angular/core"; import { ButtonComponent } from "<path-to>/shared/components/ui/button/button.component"; import { DROPDOWN_MENU_COMPONENTS } from "<path-to>/shared/components/ui/dropdown/dropdown-menu"; @Component({ selector: "app-dropdown-example", imports: [ButtonComponent, DROPDOWN_MENU_COMPONENTS], template: ` <button app-button size="sm" variant="outline" [appDropdownMenuTrigger]="accountMenu" > Account Menu </button> <ng-template #accountMenu> <app-dropdown-menu-content className="w-44"> <app-dropdown-menu-item>Edit Profile</app-dropdown-menu-item> <app-dropdown-menu-item>Account Settings</app-dropdown-menu-item> <app-dropdown-menu-item>Support</app-dropdown-menu-item> <app-dropdown-menu-separator /> <app-dropdown-menu-item variant="destructive"> Sign Out </app-dropdown-menu-item> </app-dropdown-menu-content> </ng-template> `, }) export class DropdownExampleComponent {}

Creating & Extending UI Primitives

When creating or extending a UI primitive in src/app/shared/components/ui/:

  1. Pick a Headless Foundation: Use an Angular CDK directive (for example CdkMenuItem) or a Spartan Brain directive (for example BrnButton) as a hostDirective to inherit behaviour and accessibility.
  2. Use Directives for Native Elements: Style native elements with an attribute selector (for example table[app-table], label[app-field-label]) so the template stays semantic.
  3. Define Variants: Keep variant and size classes as typed constants (for example ButtonVariant, ButtonSize) inside the component file, using Tailwind CSS v4 tokens.
  4. Attach data-slot: Set a descriptive data-slot attribute in host on each primitive to support styling hooks and parent targeting.
  5. Accept className: Add a className input and append it to the host classes so consumers can override styles.
  6. Check Accessibility: Review the CDK and Brain API references for required roles and attributes to ensure a valid ARIA tree.

Forms & Validation

TailAdmin Angular manages forms with Angular Reactive Forms (FormGroup, FormControl, Validators). Accessible form controls are constructed with the Field directives (app-field, app-field-label, app-field-error, app-field-description) exported as FIELD_IMPORTS from ui/field/field.

Form Definition

Define the form with typed, non-nullable controls and the built-in validators:

import { FormControl, FormGroup, Validators } from "@angular/forms"; export interface LoginFormData { email: string; password: string; } export const loginForm = () => new FormGroup({ email: new FormControl("", { nonNullable: true, validators: [Validators.required, Validators.email], }), password: new FormControl("", { nonNullable: true, validators: [Validators.required, Validators.minLength(6)], }), });

Form Component Implementation

Bind the group with [formGroup], connect each input with formControlName, and show a field’s error after it has been touched or edited:

import { Component } from "@angular/core"; import { ReactiveFormsModule } from "@angular/forms"; import { ButtonComponent } from "<path-to>/shared/components/ui/button/button.component"; import { FIELD_IMPORTS } from "<path-to>/shared/components/ui/field/field"; import { InputDirective } from "<path-to>/shared/components/ui/input/input.directive"; import { loginForm, type LoginFormData } from "./login-form"; @Component({ selector: "app-login-form", imports: [ReactiveFormsModule, ButtonComponent, InputDirective, ...FIELD_IMPORTS], template: ` <form [formGroup]="form" (ngSubmit)="onSubmit()" class="space-y-4" novalidate> <div app-field [attr.data-invalid]="isInvalid('email') ? 'true' : null"> <label app-field-label>Email</label> <input app-input formControlName="email" type="email" placeholder="[email protected]" [attr.aria-invalid]="isInvalid('email') ? 'true' : null" /> @if (isInvalid("email")) { <div app-field-error>{{ errorOf("email") }}</div> } </div> <div app-field [attr.data-invalid]="isInvalid('password') ? 'true' : null"> <label app-field-label>Password</label> <input app-input formControlName="password" type="password" placeholder="Enter password" [attr.aria-invalid]="isInvalid('password') ? 'true' : null" /> @if (isInvalid("password")) { <div app-field-error>{{ errorOf("password") }}</div> } </div> <button app-button type="submit" class="w-full" [disabled]="isSubmitting"> {{ isSubmitting ? "Signing in..." : "Sign In" }} </button> </form> `, }) export class LoginFormComponent { form = loginForm(); isSubmitting = false; isInvalid(name: keyof LoginFormData): boolean { const control = this.form.controls[name]; return control.invalid && (control.dirty || control.touched); } errorOf(name: keyof LoginFormData): string { const errors = this.form.controls[name].errors; if (!errors) return ""; if (errors["required"]) return `${name} is required`; if (errors["email"]) return "Please enter a valid email address"; if (errors["minlength"]) return "Password must be at least 6 characters"; return ""; } onSubmit() { if (this.form.invalid) { this.form.markAllAsTouched(); return; } this.isSubmitting = true; const data: LoginFormData = this.form.getRawValue(); console.log("Form data:", data); this.isSubmitting = false; } }
  • Validation State: Setting data-invalid on app-field and aria-invalid on the input triggers Tailwind error styling and ensures accessible feedback.
  • Accessible Errors: app-field-error renders the error text with role="alert" for screen reader announcements.
  • Show Errors Late: Checking dirty || touched keeps errors hidden until the user interacts with the field. markAllAsTouched() reveals every error on a failed submit.

Tables

TailAdmin Angular provides semantic table directives under ui/table (app-table, app-table-header, app-table-body, app-table-row, app-table-cell), exported together as TABLE_IMPORTS. TailAdmin Angular also ships with Tanstack Table  (@tanstack/angular-table) for building complex and interactive tables.

Basic Table

Create a basic table by applying the directives to native table elements:

import { Component, Input } from "@angular/core"; import { TABLE_IMPORTS } from "<path-to>/shared/components/ui/table"; interface User { id: string; name: string; role: string; status: string; } @Component({ selector: "app-basic-table-example", imports: [...TABLE_IMPORTS], template: ` <table app-table> <thead app-table-header class="border-b border-gray-100 dark:border-white/5"> <tr app-table-row> <th app-table-cell class="px-5 py-3 text-start text-xs font-medium text-gray-500 dark:text-gray-400">User</th> <th app-table-cell class="px-5 py-3 text-start text-xs font-medium text-gray-500 dark:text-gray-400">Role</th> <th app-table-cell class="px-5 py-3 text-start text-xs font-medium text-gray-500 dark:text-gray-400">Status</th> </tr> </thead> <tbody app-table-body class="divide-y divide-gray-100 dark:divide-white/5"> @for (user of users; track user.id) { <tr app-table-row> <td app-table-cell class="px-5 py-4 text-sm font-medium text-gray-800 dark:text-white/90">{{ user.name }}</td> <td app-table-cell class="px-5 py-4 text-sm text-gray-500 dark:text-gray-400">{{ user.role }}</td> <td app-table-cell class="px-5 py-4 text-sm text-gray-500 dark:text-gray-400">{{ user.status }}</td> </tr> } </tbody> </table> `, }) export class BasicTableExampleComponent { @Input() users: User[] = []; }

Advance Data Table

For advanced features such as sorting, global search filtering, and pagination, combine the table directives with @tanstack/angular-table. Check Tanstack Table documentation here  for more details.

Register only the features and functions you use with tableFeatures, define columns with createColumnHelper, and keep the table state in signals that you read inside injectTable:

import { Component, signal } from "@angular/core"; import { columnFilteringFeature, createColumnHelper, createFilteredRowModel, createPaginatedRowModel, createSortedRowModel, globalFilteringFeature, injectTable, rowPaginationFeature, rowSortingFeature, sortFn_alphanumeric, tableFeatures, type PaginationState, type SortingState, type Updater, } from "@tanstack/angular-table"; import { TABLE_IMPORTS } from "<path-to>/shared/components/ui/table"; interface User { id: number; name: string; role: string; } const features = tableFeatures({ columnFilteringFeature, globalFilteringFeature, rowSortingFeature, rowPaginationFeature, filteredRowModel: createFilteredRowModel(), sortedRowModel: createSortedRowModel(), paginatedRowModel: createPaginatedRowModel(), sortFns: { alphanumeric: sortFn_alphanumeric }, }); const columnHelper = createColumnHelper<typeof features, User>(); const columns = columnHelper.columns([ columnHelper.accessor("name", { header: "Name", sortFn: "alphanumeric" }), columnHelper.accessor("role", { header: "Role", sortFn: "alphanumeric" }), ]); const resolve = <T>(updater: Updater<T>, previous: T): T => typeof updater === "function" ? (updater as (old: T) => T)(previous) : updater; @Component({ selector: "app-data-table-example", imports: [...TABLE_IMPORTS], template: ` <input [value]="searchQuery()" (input)="onSearch($any($event.target).value)" placeholder="Search..." /> <table app-table> <thead app-table-header> <tr app-table-row> <th app-table-cell> <button type="button" (click)="table.getColumn('name')?.toggleSorting()">Name</button> </th> <th app-table-cell>Role</th> </tr> </thead> <tbody app-table-body> @for (row of table.getRowModel().rows; track row.id) { <tr app-table-row> <td app-table-cell>{{ row.original.name }}</td> <td app-table-cell>{{ row.original.role }}</td> </tr> } </tbody> </table> `, }) export class DataTableExampleComponent { private readonly data = signal<User[]>([ { id: 1, name: "Lindsey Curtis", role: "Designer" }, { id: 2, name: "Kaiya George", role: "Developer" }, ]); readonly searchQuery = signal(""); private readonly sorting = signal<SortingState>([]); private readonly pagination = signal<PaginationState>({ pageIndex: 0, pageSize: 10, }); readonly table = injectTable(() => ({ features, data: this.data(), columns, getRowId: (row: User) => String(row.id), state: { globalFilter: this.searchQuery(), sorting: this.sorting(), pagination: this.pagination(), }, onSortingChange: (updater: Updater<SortingState>) => this.sorting.update((old) => resolve(updater, old)), onPaginationChange: (updater: Updater<PaginationState>) => this.pagination.update((old) => resolve(updater, old)), })); onSearch(query: string) { this.searchQuery.set(query); this.pagination.update((old) => ({ ...old, pageIndex: 0 })); } }

Complete working examples (Data Tables One, Two and Three, the product list, invoice list and ticket list) are in src/app/pages/tables/components/ and the feature folders under src/app/pages/.

Last updated on