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.
Dropdown Menu
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/:
- Pick a Headless Foundation: Use an Angular CDK directive (for example
CdkMenuItem) or a Spartan Brain directive (for exampleBrnButton) as ahostDirectiveto inherit behaviour and accessibility. - 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. - Define Variants: Keep variant and size classes as typed constants (for example
ButtonVariant,ButtonSize) inside the component file, using Tailwind CSS v4 tokens. - Attach
data-slot: Set a descriptivedata-slotattribute inhoston each primitive to support styling hooks and parent targeting. - Accept
className: Add aclassNameinput and append it to the host classes so consumers can override styles. - 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-invalidonapp-fieldandaria-invalidon the input triggers Tailwind error styling and ensures accessible feedback. - Accessible Errors:
app-field-errorrenders the error text withrole="alert"for screen reader announcements. - Show Errors Late: Checking
dirty || touchedkeeps 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/.