Table
Tables display structured data in rows and columns with support for sorting, selection, column resizing, and infinite scrolling.
Import
import { AvTableImports } from '@avesra/angular';Usage
| Name | Role | Status | |
|---|---|---|---|
| Kate Moore | CEO | Active | [email protected] |
| John Smith | CTO | Active | [email protected] |
| Sara Johnson | CMO | On Leave | [email protected] |
| Michael Brown | CFO | Active | [email protected] |
import { Component } from '@angular/core';
import { AvTableImports } from '@avesra/angular';
@Component({
selector: 'app-table-basic-demo',
imports: [AvTableImports],
template: `<div class="w-full max-w-3xl">
<div av-table>
<div av-table-scroll-container>
<table av-table-content aria-label="Team members" class="min-w-[600px]">
<thead av-table-header>
<tr>
<th av-table-column>Name</th>
<th av-table-column>Role</th>
<th av-table-column>Status</th>
<th av-table-column>Email</th>
</tr>
</thead>
<tbody av-table-body>
<tr av-table-row>
<td av-table-cell>Kate Moore</td>
<td av-table-cell>CEO</td>
<td av-table-cell>Active</td>
<td av-table-cell>kate@acme.com</td>
</tr>
<tr av-table-row>
<td av-table-cell>John Smith</td>
<td av-table-cell>CTO</td>
<td av-table-cell>Active</td>
<td av-table-cell>john@acme.com</td>
</tr>
<tr av-table-row>
<td av-table-cell>Sara Johnson</td>
<td av-table-cell>CMO</td>
<td av-table-cell>On Leave</td>
<td av-table-cell>sara@acme.com</td>
</tr>
<tr av-table-row>
<td av-table-cell>Michael Brown</td>
<td av-table-cell>CFO</td>
<td av-table-cell>Active</td>
<td av-table-cell>michael@acme.com</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>`,
})
export class TableBasicDemo {}Anatomy
Compose Table with attribute selectors on av-table, av-table-scroll-container, av-table-content, av-table-header, av-table-column, av-table-body, av-table-row, and av-table-cell. Optional av-table-footer, av-table-resizable-container, av-table-column-resizer, av-table-load-more, and av-table-load-more-content.
<div av-table variant="primary">
<div av-table-scroll-container>
<table av-table-content aria-label="Example table">
<thead av-table-header>
<tr>
<th av-table-column allows-sorting>Name</th>
<th av-table-column>Role</th>
</tr>
</thead>
<tbody av-table-body>
<tr av-table-row>
<td av-table-cell>Kate Moore</td>
<td av-table-cell>CEO</td>
</tr>
</tbody>
</table>
</div>
<div av-table-footer><!-- Optional footer content --></div>
</div>
<!-- Optional parts -->
<!-- <div av-table-resizable-container>…</div> -->
<!-- <div av-table-column-resizer></div> -->
<!-- <tr av-table-load-more><td><div av-table-load-more-content>…</div></td></tr> -->Secondary Variant
Set variant="secondary" on av-table for a flat surface with standalone rounded headers.
| Name | Role | Status | |
|---|---|---|---|
| Kate Moore | CEO | Active | [email protected] |
| John Smith | CTO | Active | [email protected] |
| Sara Johnson | CMO | On Leave | [email protected] |
| Michael Brown | CFO | Active | [email protected] |
import { Component } from '@angular/core';
import { AvTableImports } from '@avesra/angular';
@Component({
selector: 'app-table-secondary-variant-demo',
imports: [AvTableImports],
template: `<div class="w-full max-w-3xl">
<div av-table variant="secondary">
<div av-table-scroll-container>
<table av-table-content aria-label="Team members" class="min-w-[600px]">
<thead av-table-header>
<tr>
<th av-table-column>Name</th>
<th av-table-column>Role</th>
<th av-table-column>Status</th>
<th av-table-column>Email</th>
</tr>
</thead>
<tbody av-table-body>
<tr av-table-row>
<td av-table-cell>Kate Moore</td>
<td av-table-cell>CEO</td>
<td av-table-cell>Active</td>
<td av-table-cell>kate@acme.com</td>
</tr>
<tr av-table-row>
<td av-table-cell>John Smith</td>
<td av-table-cell>CTO</td>
<td av-table-cell>Active</td>
<td av-table-cell>john@acme.com</td>
</tr>
<tr av-table-row>
<td av-table-cell>Sara Johnson</td>
<td av-table-cell>CMO</td>
<td av-table-cell>On Leave</td>
<td av-table-cell>sara@acme.com</td>
</tr>
<tr av-table-row>
<td av-table-cell>Michael Brown</td>
<td av-table-cell>CFO</td>
<td av-table-cell>Active</td>
<td av-table-cell>michael@acme.com</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>`,
})
export class TableSecondaryVariantDemo {}Async Loading
Use av-table-load-more with av-table-load-more-content for a load-more sentinel row. Trigger loading from a button, scroll observer, or other app logic — the parts are presentational.
| Name | Role | Status | |
|---|---|---|---|
| Kate Moore | CEO | Active | [email protected] |
| John Smith | CTO | Active | [email protected] |
| Sara Johnson | CMO | On Leave | [email protected] |
| Michael Brown | CFO | Active | [email protected] |
| Emily Davis | Product Manager | Inactive | [email protected] |
| Davis Wilson | Lead Designer | Active | [email protected] |
import { Component, computed, signal } from '@angular/core';
import {
AvChipImports,
AvSpinnerComponent,
AvTableImports,
type AvChipColor,
} from '@avesra/angular';
@Component({
selector: 'app-table-async-loading-demo',
imports: [
AvTableImports,
AvChipImports,
AvSpinnerComponent,
],
template: `<div class="w-full max-w-3xl">
<div av-table>
<div av-table-scroll-container class="h-[280px] overflow-y-auto">
<table av-table-content aria-label="Async loading table" class="min-w-[600px]">
<thead av-table-header class="sticky top-0 z-10 bg-surface-secondary">
<tr>
<th av-table-column>Name</th>
<th av-table-column>Role</th>
<th av-table-column>Status</th>
<th av-table-column>Email</th>
</tr>
</thead>
<tbody av-table-body>
@for (user of items(); track user.id) {
<tr av-table-row>
<td av-table-cell>{{ user.name }}</td>
<td av-table-cell>{{ user.role }}</td>
<td av-table-cell>
<span av-chip [color]="statusColor(user.status)" size="sm" variant="soft">
<span av-chip-label>{{ user.status }}</span>
</span>
</td>
<td av-table-cell>{{ user.email }}</td>
</tr>
}
@if (hasMore()) {
<tr av-table-load-more>
<td av-table-cell colspan="4">
<div av-table-load-more-content>
@if (loading()) {
<span av-spinner size="md"></span>
} @else {
<button
type="button"
class="text-sm text-accent underline-offset-2 hover:underline"
(click)="loadMore()"
>
Load more
</button>
}
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>`,
})
export class TableAsyncLoadingDemo {
private readonly allUsers = [
{ id: 1, name: 'Kate Moore', role: 'CEO', status: 'Active', email: '[email protected]' },
{ id: 2, name: 'John Smith', role: 'CTO', status: 'Active', email: '[email protected]' },
{ id: 3, name: 'Sara Johnson', role: 'CMO', status: 'On Leave', email: '[email protected]' },
{ id: 4, name: 'Michael Brown', role: 'CFO', status: 'Active', email: '[email protected]' },
{ id: 5, name: 'Emily Davis', role: 'Product Manager', status: 'Inactive', email: '[email protected]' },
{ id: 6, name: 'Davis Wilson', role: 'Lead Designer', status: 'Active', email: '[email protected]' },
{
id: 7,
name: 'Olivia Martinez',
role: 'Frontend Engineer',
status: 'Active',
email: '[email protected]',
},
{
id: 8,
name: 'James Taylor',
role: 'Backend Engineer',
status: 'Active',
email: '[email protected]',
},
{
id: 9,
name: 'Sophia Anderson',
role: 'QA Engineer',
status: 'On Leave',
email: '[email protected]',
},
{ id: 10, name: 'Liam Thomas', role: 'DevOps Engineer', status: 'Active', email: '[email protected]' },
{
id: 11,
name: 'Lucas Martinez',
role: 'Product Manager',
status: 'Active',
email: '[email protected]',
},
{
id: 12,
name: 'Emma Johnson',
role: 'Frontend Engineer',
status: 'Active',
email: '[email protected]',
},
];
readonly items = signal(this.allUsers.slice(0, 6));
readonly loading = signal(false);
readonly hasMore = computed(() => this.items().length < this.allUsers.length);
private readonly statusColorMap: Record<string, AvChipColor> = {
Active: 'success',
Inactive: 'danger',
'On Leave': 'warning',
};
statusColor(status: string): AvChipColor {
return this.statusColorMap[status] ?? 'accent';
}
loadMore(): void {
if (!this.hasMore() || this.loading()) return;
this.loading.set(true);
setTimeout(() => {
this.items.update((prev) => this.allUsers.slice(0, prev.length + 6));
this.loading.set(false);
}, 1200);
}
}Sorting
Mark sortable columns with allows-sorting on av-table-column. Avesra Table is a presentational compound — manage sort state in your component (e.g. a signal), bind [attr.aria-sort], and re-order rows yourself. There is no built-in SortableColumnHeader; render a chevron from your sort direction.
| Name | Role | Status | |
|---|---|---|---|
| Emily Davis | Product Manager | Inactive | [email protected] |
| John Smith | CTO | Active | [email protected] |
| Kate Moore | CEO | Active | [email protected] |
| Michael Brown | CFO | Active | [email protected] |
| Sara Johnson | CMO | On Leave | [email protected] |
import { Component, computed, signal } from '@angular/core';
import { AvTableImports } from '@avesra/angular';
@Component({
selector: 'app-table-sorting-demo',
imports: [AvTableImports],
template: `<div class="w-full max-w-3xl">
<div av-table>
<div av-table-scroll-container>
<table av-table-content aria-label="Sortable table" class="min-w-[600px]">
<thead av-table-header>
<tr>
@for (col of columns; track col) {
<th
av-table-column
allows-sorting
[attr.aria-sort]="sortAria(col)"
(click)="toggleSort(col)"
>
<span class="flex items-center justify-between gap-2">
{{ labels[col] }}
@if (sortDirection(col); as direction) {
<svg
class="size-3 shrink-0 transition-transform duration-100 ease-out"
[class.rotate-180]="direction === 'descending'"
viewBox="0 0 16 16"
fill="currentColor"
aria-hidden="true"
>
<path d="M8 3.5 4 9.5h8L8 3.5Z" />
</svg>
}
</span>
</th>
}
</tr>
</thead>
<tbody av-table-body>
@for (user of sortedUsers(); track user.id) {
<tr av-table-row>
<td av-table-cell>{{ user.name }}</td>
<td av-table-cell>{{ user.role }}</td>
<td av-table-cell>{{ user.status }}</td>
<td av-table-cell>{{ user.email }}</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>`,
})
export class TableSortingDemo {
readonly columns = ['name', 'role', 'status', 'email'] as const;
readonly labels = {
name: 'Name',
role: 'Role',
status: 'Status',
email: 'Email',
} as const;
private readonly users = [
{ id: 1, name: 'Kate Moore', role: 'CEO', status: 'Active', email: '[email protected]' },
{ id: 2, name: 'John Smith', role: 'CTO', status: 'Active', email: '[email protected]' },
{ id: 3, name: 'Sara Johnson', role: 'CMO', status: 'On Leave', email: '[email protected]' },
{ id: 4, name: 'Michael Brown', role: 'CFO', status: 'Active', email: '[email protected]' },
{
id: 5,
name: 'Emily Davis',
role: 'Product Manager',
status: 'Inactive',
email: '[email protected]',
},
];
readonly sortDescriptor = signal({ column: 'name', direction: 'ascending' as const });
readonly sortedUsers = computed(() => {
const { column, direction } = this.sortDescriptor();
return [...this.users].sort((a, b) => {
const cmp = String(a[column]).localeCompare(String(b[column]));
return direction === 'descending' ? -cmp : cmp;
});
});
toggleSort(column: 'name' | 'role' | 'status' | 'email'): void {
this.sortDescriptor.update((current) => {
if (current.column === column) {
return {
column,
direction: current.direction === 'ascending' ? 'descending' : 'ascending',
};
}
return { column, direction: 'ascending' };
});
}
sortAria(column: 'name' | 'role' | 'status' | 'email'): 'ascending' | 'descending' | 'none' {
const descriptor = this.sortDescriptor();
return descriptor.column === column ? descriptor.direction : 'none';
}
sortDirection(column: 'name' | 'role' | 'status' | 'email'): 'ascending' | 'descending' | null {
const descriptor = this.sortDescriptor();
return descriptor.column === column ? descriptor.direction : null;
}
}Selection
Compose row selection with av-checkbox in a selection column and bind [selected] on av-table-row. Selection state lives in your app — there is no selectionMode on av-table-content.
| Name | Role | Status | ||
|---|---|---|---|---|
| Kate Moore | CEO | Active | [email protected] | |
| John Smith | CTO | Active | [email protected] | |
| Sara Johnson | CMO | On Leave | [email protected] | |
| Michael Brown | CFO | Active | [email protected] |
Selected: None
import { Component, computed, signal } from '@angular/core';
import {
AvCheckboxImports,
AvTableImports,
} from '@avesra/angular';
@Component({
selector: 'app-table-selection-demo',
imports: [
AvTableImports,
AvCheckboxImports,
],
template: `<div class="flex w-full max-w-3xl flex-col gap-3">
<div av-table>
<div av-table-scroll-container>
<table av-table-content aria-label="Table with selection" class="min-w-[600px]">
<thead av-table-header>
<tr>
<th av-table-column class="w-12 pr-0">
<div
av-checkbox
variant="secondary"
aria-label="Select all"
[selected]="allSelected()"
[indeterminate]="someSelected()"
(selectedChange)="toggleSelectAll($event)"
>
<span av-checkbox-control>
<span av-checkbox-indicator></span>
</span>
</div>
</th>
<th av-table-column>Name</th>
<th av-table-column>Role</th>
<th av-table-column>Status</th>
<th av-table-column>Email</th>
</tr>
</thead>
<tbody av-table-body>
@for (user of users; track user.id) {
<tr av-table-row [selected]="isSelected(user.id)">
<td av-table-cell class="pr-0">
<div
av-checkbox
variant="secondary"
[attr.aria-label]="'Select ' + user.name"
[selected]="isSelected(user.id)"
(selectedChange)="setSelected(user.id, $event)"
>
<span av-checkbox-control>
<span av-checkbox-indicator></span>
</span>
</div>
</td>
<td av-table-cell>{{ user.name }}</td>
<td av-table-cell>{{ user.role }}</td>
<td av-table-cell>{{ user.status }}</td>
<td av-table-cell>{{ user.email }}</td>
</tr>
}
</tbody>
</table>
</div>
</div>
<p class="text-sm text-muted">
Selected:
<span class="font-medium">{{ selectedLabel() }}</span>
</p>
</div>`,
})
export class TableSelectionDemo {
readonly users = [
{ id: 1, name: 'Kate Moore', role: 'CEO', status: 'Active', email: '[email protected]' },
{ id: 2, name: 'John Smith', role: 'CTO', status: 'Active', email: '[email protected]' },
{ id: 3, name: 'Sara Johnson', role: 'CMO', status: 'On Leave', email: '[email protected]' },
{ id: 4, name: 'Michael Brown', role: 'CFO', status: 'Active', email: '[email protected]' },
];
readonly selectedIds = signal<ReadonlySet<number>>(new Set());
readonly allSelected = computed(
() =>
this.users.length > 0 &&
this.users.every((user) => this.selectedIds().has(user.id)),
);
readonly someSelected = computed(() => {
const count = this.selectedIds().size;
return count > 0 && count < this.users.length;
});
readonly selectedLabel = computed(() => {
const ids = this.selectedIds();
if (ids.size === 0) return 'None';
if (ids.size === this.users.length) return 'All';
return Array.from(ids).join(', ');
});
isSelected(id: number): boolean {
return this.selectedIds().has(id);
}
setSelected(id: number, selected: boolean): void {
this.selectedIds.update((current) => {
const next = new Set(current);
if (selected) next.add(id);
else next.delete(id);
return next;
});
}
toggleSelectAll(selected: boolean): void {
this.selectedIds.set(selected ? new Set(this.users.map((u) => u.id)) : new Set());
}
}Pagination
Use av-table-footer to place av-pagination below the table. Slice the row data in your component based on the current page.
| Name | Role | Status | |
|---|---|---|---|
| Kate Moore | CEO | Active | [email protected] |
| John Smith | CTO | Active | [email protected] |
| Sara Johnson | CMO | On Leave | [email protected] |
| Michael Brown | CFO | Active | [email protected] |
import { Component, computed, signal } from '@angular/core';
import {
getPaginationRange,
AvPaginationImports,
AvTableImports,
} from '@avesra/angular';
@Component({
selector: 'app-table-pagination-demo',
imports: [
AvTableImports,
AvPaginationImports,
],
template: `<div class="w-full max-w-3xl">
<div av-table>
<div av-table-scroll-container>
<table av-table-content aria-label="Table with pagination" class="min-w-[600px]">
<thead av-table-header>
<tr>
<th av-table-column>Name</th>
<th av-table-column>Role</th>
<th av-table-column>Status</th>
<th av-table-column>Email</th>
</tr>
</thead>
<tbody av-table-body>
@for (user of pageItems(); track user.id) {
<tr av-table-row>
<td av-table-cell>{{ user.name }}</td>
<td av-table-cell>{{ user.role }}</td>
<td av-table-cell>{{ user.status }}</td>
<td av-table-cell>{{ user.email }}</td>
</tr>
}
</tbody>
</table>
</div>
<div av-table-footer class="w-full">
<av-pagination size="sm" class="w-full">
<div av-pagination-summary>
{{ startItem() }} to {{ endItem() }} of {{ users.length }} results
</div>
<ul av-pagination-content>
<li av-pagination-item>
<button
av-pagination-prev
[disabled]="page() === 1"
(click)="page.set(page() - 1)"
>
<span av-pagination-prev-icon></span>
Prev
</button>
</li>
@for (item of range(); track $index) {
<li av-pagination-item>
@if (item === 'ellipsis') {
<span av-pagination-ellipsis></span>
} @else {
<button
av-pagination-link
[active]="item === page()"
(click)="page.set(item)"
>
{{ item }}
</button>
}
</li>
}
<li av-pagination-item>
<button
av-pagination-next
[disabled]="page() === totalPages"
(click)="page.set(page() + 1)"
>
Next
<span av-pagination-next-icon></span>
</button>
</li>
</ul>
</av-pagination>
</div>
</div>
</div>`,
})
export class TablePaginationDemo {
readonly users = [
{ id: 1, name: 'Kate Moore', role: 'CEO', status: 'Active', email: '[email protected]' },
{ id: 2, name: 'John Smith', role: 'CTO', status: 'Active', email: '[email protected]' },
{ id: 3, name: 'Sara Johnson', role: 'CMO', status: 'On Leave', email: '[email protected]' },
{ id: 4, name: 'Michael Brown', role: 'CFO', status: 'Active', email: '[email protected]' },
{ id: 5, name: 'Emily Davis', role: 'Product Manager', status: 'Inactive', email: '[email protected]' },
{ id: 6, name: 'Davis Wilson', role: 'Lead Designer', status: 'Active', email: '[email protected]' },
{
id: 7,
name: 'Olivia Martinez',
role: 'Frontend Engineer',
status: 'Active',
email: '[email protected]',
},
{
id: 8,
name: 'James Taylor',
role: 'Backend Engineer',
status: 'Active',
email: '[email protected]',
},
];
readonly page = signal(1);
readonly rowsPerPage = 4;
readonly totalPages = Math.ceil(this.users.length / this.rowsPerPage);
readonly range = computed(() => getPaginationRange(this.page(), this.totalPages));
readonly pageItems = computed(() => {
const start = (this.page() - 1) * this.rowsPerPage;
return this.users.slice(start, start + this.rowsPerPage);
});
readonly startItem = computed(() => (this.page() - 1) * this.rowsPerPage + 1);
readonly endItem = computed(() =>
Math.min(this.page() * this.rowsPerPage, this.users.length),
);
}Column Resizing
Wrap the table in av-table-resizable-container and add av-table-column-resizer inside each resizable av-table-column. The resizer provides the drag-handle styling; wire resize behavior in your app if needed.
| Name | Role | Status | |
|---|---|---|---|
| Kate Moore | CEO | Active | [email protected] |
| John Smith | CTO | Active | [email protected] |
| Sara Johnson | CMO | On Leave | [email protected] |
| Michael Brown | CFO | Active | [email protected] |
| Emily Davis | Product Manager | Inactive | [email protected] |
import { Component } from '@angular/core';
import {
AvChipImports,
AvTableImports,
} from '@avesra/angular';
@Component({
selector: 'app-table-column-resizing-demo',
imports: [
AvTableImports,
AvChipImports,
],
template: `<div class="w-full max-w-3xl">
<div av-table>
<div av-table-resizable-container>
<table av-table-content aria-label="Table with resizable columns" class="min-w-[700px]">
<thead av-table-header>
<tr>
<th av-table-column class="relative min-w-40">
Name
<div av-table-column-resizer></div>
</th>
<th av-table-column class="relative min-w-52">
Role
<div av-table-column-resizer></div>
</th>
<th av-table-column class="relative min-w-28">
Status
<div av-table-column-resizer></div>
</th>
<th av-table-column class="min-w-48">Email</th>
</tr>
</thead>
<tbody av-table-body>
<tr av-table-row>
<td av-table-cell>Kate Moore</td>
<td av-table-cell>CEO</td>
<td av-table-cell>
<span av-chip color="success" size="sm" variant="soft">
<span av-chip-label>Active</span>
</span>
</td>
<td av-table-cell>kate@acme.com</td>
</tr>
<tr av-table-row>
<td av-table-cell>John Smith</td>
<td av-table-cell>CTO</td>
<td av-table-cell>
<span av-chip color="success" size="sm" variant="soft">
<span av-chip-label>Active</span>
</span>
</td>
<td av-table-cell>john@acme.com</td>
</tr>
<tr av-table-row>
<td av-table-cell>Sara Johnson</td>
<td av-table-cell>CMO</td>
<td av-table-cell>
<span av-chip color="warning" size="sm" variant="soft">
<span av-chip-label>On Leave</span>
</span>
</td>
<td av-table-cell>sara@acme.com</td>
</tr>
<tr av-table-row>
<td av-table-cell>Michael Brown</td>
<td av-table-cell>CFO</td>
<td av-table-cell>
<span av-chip color="success" size="sm" variant="soft">
<span av-chip-label>Active</span>
</span>
</td>
<td av-table-cell>michael@acme.com</td>
</tr>
<tr av-table-row>
<td av-table-cell>Emily Davis</td>
<td av-table-cell>Product Manager</td>
<td av-table-cell>
<span av-chip color="danger" size="sm" variant="soft">
<span av-chip-label>Inactive</span>
</span>
</td>
<td av-table-cell>emily@acme.com</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>`,
})
export class TableColumnResizingDemo {}Empty State
There is no renderEmptyState prop. When the data array is empty, render a placeholder row (or any markup) inside av-table-body with @if.
| Name | Role | Status | |
|---|---|---|---|
No results found | |||
import { Component } from '@angular/core';
import { AvTableImports } from '@avesra/angular';
@Component({
selector: 'app-table-empty-state-demo',
imports: [AvTableImports],
template: `<div class="w-full max-w-3xl">
<div av-table class="min-h-[200px]">
<div av-table-scroll-container>
<table av-table-content aria-label="Empty table" class="min-w-[600px]">
<thead av-table-header>
<tr>
<th av-table-column>Name</th>
<th av-table-column>Role</th>
<th av-table-column>Status</th>
<th av-table-column>Email</th>
</tr>
</thead>
<tbody av-table-body>
@if (users.length === 0) {
<tr av-table-row>
<td av-table-cell colspan="4">
<div class="flex flex-col items-center justify-center gap-3 py-10 text-center">
<svg
class="size-6 text-muted"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M20 7H4m16 0v11a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7m16 0-1.5-3h-13L4 7m4 4v6m4-6v6m4-6v6"
/>
</svg>
<span class="text-sm text-muted">No results found</span>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>`,
})
export class TableEmptyStateDemo {
readonly users: never[] = [];
}Custom Cells
Project any content into av-table-cell — avatars, chips, buttons, and more.
| Member | Role | Status | Actions | |
|---|---|---|---|---|
KMKate Moore[email protected] | Chief Executive Officer | Active | ||
JSJohn Smith[email protected] | Chief Technology Officer | Active | ||
SJSara Johnson[email protected] | Chief Marketing Officer | On Leave | ||
MBMichael Brown[email protected] | Chief Financial Officer | Active | ||
EDEmily Davis[email protected] | Product Manager | Inactive |
import { Component, computed, signal } from '@angular/core';
import {
AvAvatarImports,
AvButtonComponent,
AvCheckboxImports,
AvChipImports,
AvTableImports,
type AvChipColor,
} from '@avesra/angular';
import { AppIconComponent } from '../../components/app-icon/app-icon.component';
@Component({
selector: 'app-table-custom-cells-demo',
imports: [
AvTableImports,
AvAvatarImports,
AvChipImports,
AvButtonComponent,
AvCheckboxImports,
AppIconComponent,
],
template: `<div class="w-full max-w-4xl">
<div av-table>
<div av-table-scroll-container>
<table av-table-content aria-label="Table with custom cells" class="min-w-[800px]">
<thead av-table-header>
<tr>
<th av-table-column class="w-12 pr-0">
<div
av-checkbox
variant="secondary"
aria-label="Select all"
[selected]="allSelected()"
[indeterminate]="someSelected()"
(selectedChange)="toggleSelectAll($event)"
>
<span av-checkbox-control>
<span av-checkbox-indicator></span>
</span>
</div>
</th>
<th av-table-column>Member</th>
<th av-table-column>Role</th>
<th av-table-column>Status</th>
<th av-table-column class="text-end">Actions</th>
</tr>
</thead>
<tbody av-table-body>
@for (user of users; track user.id) {
<tr av-table-row [selected]="isSelected(user.id)">
<td av-table-cell class="pr-0">
<div
av-checkbox
variant="secondary"
[attr.aria-label]="'Select ' + user.name"
[selected]="isSelected(user.id)"
(selectedChange)="setSelected(user.id, $event)"
>
<span av-checkbox-control>
<span av-checkbox-indicator></span>
</span>
</div>
</td>
<td av-table-cell>
<div class="flex items-center gap-3">
<span av-avatar size="sm">
<img av-avatar-image [alt]="user.name" [src]="user.imageUrl" />
<span av-avatar-fallback>{{ initials(user.name) }}</span>
</span>
<div class="flex flex-col">
<span class="text-xs">{{ user.name }}</span>
<span class="text-xs text-muted">{{ user.email }}</span>
</div>
</div>
</td>
<td av-table-cell class="min-w-52">{{ user.role }}</td>
<td av-table-cell>
<span av-chip [color]="statusColorMap[user.status]" size="sm" variant="soft">
<span av-chip-label>{{ user.status }}</span>
</span>
</td>
<td av-table-cell>
<div class="flex items-center justify-end gap-1">
<button av-button size="sm" variant="tertiary" icon-only aria-label="View user">
<app-icon icon="solar:eye-linear" size="16" />
</button>
<button av-button size="sm" variant="tertiary" icon-only aria-label="Edit user">
<app-icon icon="solar:pen-linear" size="16" />
</button>
<button av-button size="sm" variant="danger-soft" icon-only aria-label="Delete user">
<app-icon icon="solar:trash-bin-trash-linear" size="16" />
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
<div av-table-footer>
<span class="text-sm text-muted">
@if (selectedCount() > 0) {
{{ selectedCount() }} of {{ users.length }} selected
} @else {
Showing {{ users.length }} results
}
</span>
</div>
</div>
</div>`,
})
export class TableCustomCellsDemo {
readonly users = [
{
id: 4586932,
name: 'Kate Moore',
imageUrl: '/images/gradients/gradient-pink-magenta.png',
role: 'Chief Executive Officer',
status: 'Active' as const,
email: '[email protected]',
},
{
id: 5273849,
name: 'John Smith',
imageUrl: '/images/gradients/gradient-cyan-blue-purple.png',
role: 'Chief Technology Officer',
status: 'Active' as const,
email: '[email protected]',
},
{
id: 7492836,
name: 'Sara Johnson',
imageUrl: '/images/gradients/gradient-blue-cyan.png',
role: 'Chief Marketing Officer',
status: 'On Leave' as const,
email: '[email protected]',
},
{
id: 8293746,
name: 'Michael Brown',
imageUrl: '/images/gradients/gradient-purple-violet.png',
role: 'Chief Financial Officer',
status: 'Active' as const,
email: '[email protected]',
},
{
id: 1234567,
name: 'Emily Davis',
imageUrl: '/images/gradients/gradient-warm-orange-yellow-red.png',
role: 'Product Manager',
status: 'Inactive' as const,
email: '[email protected]',
},
];
readonly statusColorMap: Record<'Active' | 'Inactive' | 'On Leave', AvChipColor> = {
Active: 'success',
Inactive: 'danger',
'On Leave': 'warning',
};
readonly selectedIds = signal<ReadonlySet<number>>(new Set());
readonly allSelected = computed(
() =>
this.users.length > 0 &&
this.users.every((user) => this.selectedIds().has(user.id)),
);
readonly someSelected = computed(() => {
const count = this.selectedIds().size;
return count > 0 && count < this.users.length;
});
readonly selectedCount = computed(() => this.selectedIds().size);
isSelected(id: number): boolean {
return this.selectedIds().has(id);
}
setSelected(id: number, selected: boolean): void {
this.selectedIds.update((current) => {
const next = new Set(current);
if (selected) next.add(id);
else next.delete(id);
return next;
});
}
toggleSelectAll(selected: boolean): void {
this.selectedIds.set(selected ? new Set(this.users.map((u) => u.id)) : new Set());
}
initials(name: string): string {
return name
.split(' ')
.map((part) => part[0])
.join('');
}
}Styling
Passing Tailwind CSS classes
You can customize individual Table parts:
<div av-table class="border border-accent/20">
<div av-table-scroll-container>
<table av-table-content aria-label="Custom styled table">
<thead av-table-header class="bg-accent/5">
<tr>
<th av-table-column>Name</th>
</tr>
</thead>
<tbody av-table-body>
<tr av-table-row class="hover:bg-accent/5">
<td av-table-cell>Kate Moore</td>
</tr>
</tbody>
</table>
</div>
</div>Customizing the component classes
To customize the Table component classes, use the @layer components directive. Learn more.
@layer components {
.av-table-root {
@apply relative grid w-full overflow-clip;
}
.av-table__header {
@apply bg-gray-100;
}
.av-table__column {
@apply px-4 py-2.5 text-left text-xs font-medium text-gray-600;
}
.av-table__row {
@apply border-b border-gray-200 bg-white;
}
.av-table__cell {
@apply px-4 py-3 text-sm;
}
.av-table__footer {
@apply flex items-center px-4 py-2.5;
}
}Avesra follows the BEM methodology so component variants and states stay reusable and easy to customize.
CSS Classes
The Table component uses these CSS classes:
Base Classes
.av-table-root— Root container (namedav-table-rootinstead ofav-tablebecausetableis a built-in Tailwind CSS utility).av-table__scroll-container— Horizontal scroll wrapper.av-table__content— The<table>element.av-table__header— Header row (<thead>).av-table__column— Column header cell (<th>).av-table__body— Body section (<tbody>).av-table__row— Row element (<tr>).av-table__cell— Data cell (<td>).av-table__footer— Footer container (outside the table)
Advanced Classes
.av-table__column-resizer— Drag handle for column resizing.av-table__resizable-container— Wrapper enabling column resizing.av-table__load-more— Sentinel row for infinite scrolling.av-table__load-more-content— Styled container for the loading indicator
Variant Classes
.av-table-root--primary— Gray background container with card-style body (default).av-table-root--secondary— No background, standalone rounded headers
Interactive States
The Table supports both CSS pseudo-classes and data attributes for flexibility:
- Hover:
:hoveror[data-hovered="true"](row background change) - Selected:
[data-selected="true"](row highlight) - Focus:
:focus-visibleor[data-focus-visible="true"](inset focus ring on rows) - Disabled:
[data-disabled="true"]or[aria-disabled="true"](reduced opacity) - Sortable:
[data-allows-sorting="true"](interactive cursor on columns) - Dragging:
[data-dragging="true"](reduced opacity) - Drop Target:
[data-drop-target="true"](accent background)
API
Props for div[av-table], th[av-table-column], and tr[av-table-row]. Scroll container, content, header, body, cell, footer, resizable container, column resizer, load-more, and load-more-content project content and have no inputs.
| Prop | Type | Default | Description |
|---|---|---|---|
variant | 'primary' | 'secondary' | 'primary' | Visual variant. Primary has a gray background container; secondary is flat with transparent rows (div[av-table]). |
allows-sorting | boolean | false | Whether the column supports sorting. Sets `data-allows-sorting` for styling (th[av-table-column]). |
selected | boolean | false | Selected row state. Sets `data-selected` (tr[av-table-row]). |
disabled | boolean | false | Disabled row state. Sets `data-disabled` and `aria-disabled` (tr[av-table-row]). |




