AvesraAvesrabeta

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

NameRoleStatusEmail
Kate MooreCEOActive[email protected]
John SmithCTOActive[email protected]
Sara JohnsonCMOOn Leave[email protected]
Michael BrownCFOActive[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&#64;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&#64;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&#64;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&#64;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.

NameRoleStatusEmail
Kate MooreCEOActive[email protected]
John SmithCTOActive[email protected]
Sara JohnsonCMOOn Leave[email protected]
Michael BrownCFOActive[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&#64;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&#64;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&#64;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&#64;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.

NameRoleStatusEmail
Kate MooreCEOActive[email protected]
John SmithCTOActive[email protected]
Sara JohnsonCMOOn Leave[email protected]
Michael BrownCFOActive[email protected]
Emily DavisProduct ManagerInactive[email protected]
Davis WilsonLead DesignerActive[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 Email
Emily DavisProduct ManagerInactive[email protected]
John SmithCTOActive[email protected]
Kate MooreCEOActive[email protected]
Michael BrownCFOActive[email protected]
Sara JohnsonCMOOn 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.

NameRoleStatusEmail
Kate MooreCEOActive[email protected]
John SmithCTOActive[email protected]
Sara JohnsonCMOOn Leave[email protected]
Michael BrownCFOActive[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.

NameRoleStatusEmail
Kate MooreCEOActive[email protected]
John SmithCTOActive[email protected]
Sara JohnsonCMOOn Leave[email protected]
Michael BrownCFOActive[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 Email
Kate MooreCEOActive[email protected]
John SmithCTOActive[email protected]
Sara JohnsonCMOOn Leave[email protected]
Michael BrownCFOActive[email protected]
Emily DavisProduct ManagerInactive[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&#64;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&#64;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&#64;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&#64;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&#64;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.

NameRoleStatusEmail
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.

MemberRoleStatusActions
Kate MooreKM
Chief Executive OfficerActive
John SmithJS
Chief Technology OfficerActive
Sara JohnsonSJ
Sara Johnson[email protected]
Chief Marketing OfficerOn Leave
Michael BrownMB
Michael Brown[email protected]
Chief Financial OfficerActive
Emily DavisED
Product ManagerInactive
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 (named av-table-root instead of av-table because table is 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: :hover or [data-hovered="true"] (row background change)
  • Selected: [data-selected="true"] (row highlight)
  • Focus: :focus-visible or [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.

PropTypeDefaultDescription
variant'primary' | 'secondary''primary'Visual variant. Primary has a gray background container; secondary is flat with transparent rows (div[av-table]).
allows-sortingbooleanfalseWhether the column supports sorting. Sets `data-allows-sorting` for styling (th[av-table-column]).
selectedbooleanfalseSelected row state. Sets `data-selected` (tr[av-table-row]).
disabledbooleanfalseDisabled row state. Sets `data-disabled` and `aria-disabled` (tr[av-table-row]).

Made with ❤ by SyntaxHertz. Open source and available on GitHub.