DEV Community

Ethan Walker
Ethan Walker

Posted on

Building Interactive Data Tables with AgnosUI in Svelte

AgnosUI is a framework-agnostic component library that provides accessible, production-ready UI primitives for React, Vue, Angular, and Svelte. It offers headless components that give you full control over styling while handling accessibility and complex interactions automatically. This article covers building interactive data tables with sorting, filtering, and pagination using AgnosUI's table components in Svelte. This is part 16 of a series on using AgnosUI with Svelte.

This guide walks through creating a production-ready data table with sorting, filtering, pagination, and proper accessibility features using AgnosUI components.

Prerequisites

Before starting, ensure you have:

  • A Svelte project (SvelteKit or standalone Svelte 4+)
  • Node.js 18+ and npm/pnpm/yarn
  • Basic understanding of Svelte reactivity and component composition
  • Familiarity with JavaScript array methods (map, filter, sort)

Installation

Install AgnosUI for Svelte using your preferred package manager:

npm install @agnos-ui/svelte-headless
# or
pnpm add @agnos-ui/svelte-headless
# or
yarn add @agnos-ui/svelte-headless
Enter fullscreen mode Exit fullscreen mode

The package includes all components and their logic. You'll need to import CSS separately if you want default styling.

Project Setup

After installation, you can optionally import AgnosUI's CSS for default styling. In SvelteKit, add it to your root layout:

<!-- src/routes/+layout.svelte -->
<script>
  import '@agnos-ui/svelte-headless/css/common.min.css';
</script>
Enter fullscreen mode Exit fullscreen mode

For standalone Svelte projects, import it in your main entry file:

// main.js
import '@agnos-ui/svelte-headless/css/common.min.css';
import App from './App.svelte';

const app = new App({
  target: document.body
});
Enter fullscreen mode Exit fullscreen mode

First Example / Basic Usage

Let's start with a simple table displaying data:

<!-- src/lib/SimpleTable.svelte -->
<script>
  import { createTable } from '@agnos-ui/svelte-headless';

  // Sample data
  const users = [
    { id: 1, name: 'Alice Johnson', email: 'alice@example.com', role: 'Admin', status: 'Active' },
    { id: 2, name: 'Bob Smith', email: 'bob@example.com', role: 'User', status: 'Active' },
    { id: 3, name: 'Charlie Brown', email: 'charlie@example.com', role: 'User', status: 'Inactive' },
    { id: 4, name: 'Diana Prince', email: 'diana@example.com', role: 'Moderator', status: 'Active' },
    { id: 5, name: 'Eve Wilson', email: 'eve@example.com', role: 'User', status: 'Active' }
  ];

  // Create table instance
  const table = createTable({
    data: users,
    columns: [
      { id: 'name', header: 'Name', accessor: (row) => row.name },
      { id: 'email', header: 'Email', accessor: (row) => row.email },
      { id: 'role', header: 'Role', accessor: (row) => row.role },
      { id: 'status', header: 'Status', accessor: (row) => row.status }
    ]
  });
</script>

<div class="table-container">
  <table class="table">
    <thead>
      <tr>
        {#each $table.columns as column}
          <th>{column.header}</th>
        {/each}
      </tr>
    </thead>
    <tbody>
      {#each $table.rows as row}
        <tr>
          {#each $table.columns as column}
            <td>{column.accessor(row)}</td>
          {/each}
        </tr>
      {/each}
    </tbody>
  </table>
</div>

<style>
  .table-container {
    width: 100%;
    overflow-x: auto;
  }

  .table {
    width: 100%;
    border-collapse: collapse;
    margin: 1rem 0;
  }

  .table th,
  .table td {
    padding: 0.75rem;
    text-align: left;
    border-bottom: 1px solid #e5e7eb;
  }

  .table th {
    background-color: #f9fafb;
    font-weight: 600;
    color: #374151;
  }

  .table tbody tr:hover {
    background-color: #f9fafb;
  }
</style>
Enter fullscreen mode Exit fullscreen mode

This example demonstrates:

  • createTable function: AgnosUI's table builder that manages table state
  • Columns configuration: Define table structure with headers and data accessors
  • Reactive stores: Using $table.columns and $table.rows to access table data reactively
  • Basic styling: Simple table styles for readability

Understanding the Basics

AgnosUI's table system provides a headless approach, giving you full control over markup while handling complex state management. Key concepts:

Table State Management

The createTable function returns a store with reactive properties:

  • columns: Array of column definitions
  • rows: Array of processed row data
  • sorting: Current sorting state
  • filtering: Current filtering state
  • pagination: Pagination state

Column Accessors

Each column uses an accessor function to extract data from rows:

<script>
  const column = {
    id: 'name',
    header: 'Name',
    accessor: (row) => row.name  // Extracts name from each row
  };
</script>
Enter fullscreen mode Exit fullscreen mode

Reactive Updates

All table state is reactive, so changes automatically update the UI:

<script>
  let table = createTable({ data: users, columns: [...] });

  // Adding new data automatically updates the table
  function addUser(newUser) {
    users = [...users, newUser];
    table.update({ data: users });
  }
</script>
Enter fullscreen mode Exit fullscreen mode

Practical Example / Building Something Real

Let's build a complete data table with sorting, filtering, and pagination:

<!-- src/lib/DataTable.svelte -->
<script>
  import { createTable } from '@agnos-ui/svelte-headless';
  import { writable } from 'svelte/store';

  // Sample data - in real app, this would come from an API
  const initialUsers = [
    { id: 1, name: 'Alice Johnson', email: 'alice@example.com', role: 'Admin', status: 'Active', joinDate: '2023-01-15' },
    { id: 2, name: 'Bob Smith', email: 'bob@example.com', role: 'User', status: 'Active', joinDate: '2023-02-20' },
    { id: 3, name: 'Charlie Brown', email: 'charlie@example.com', role: 'User', status: 'Inactive', joinDate: '2023-03-10' },
    { id: 4, name: 'Diana Prince', email: 'diana@example.com', role: 'Moderator', status: 'Active', joinDate: '2023-01-05' },
    { id: 5, name: 'Eve Wilson', email: 'eve@example.com', role: 'User', status: 'Active', joinDate: '2023-04-12' },
    { id: 6, name: 'Frank Miller', email: 'frank@example.com', role: 'User', status: 'Inactive', joinDate: '2023-05-18' },
    { id: 7, name: 'Grace Lee', email: 'grace@example.com', role: 'Admin', status: 'Active', joinDate: '2023-02-28' },
    { id: 8, name: 'Henry Davis', email: 'henry@example.com', role: 'Moderator', status: 'Active', joinDate: '2023-03-22' },
    { id: 9, name: 'Ivy Chen', email: 'ivy@example.com', role: 'User', status: 'Active', joinDate: '2023-06-01' },
    { id: 10, name: 'Jack Taylor', email: 'jack@example.com', role: 'User', status: 'Inactive', joinDate: '2023-04-30' }
  ];

  // Filter state
  let searchQuery = '';
  let statusFilter = 'all';
  let roleFilter = 'all';

  // Pagination state
  const pageSize = 5;
  let currentPage = writable(1);

  // Computed filtered and sorted data
  $: filteredUsers = initialUsers.filter(user => {
    const matchesSearch = !searchQuery || 
      user.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
      user.email.toLowerCase().includes(searchQuery.toLowerCase());

    const matchesStatus = statusFilter === 'all' || user.status === statusFilter;
    const matchesRole = roleFilter === 'all' || user.role === roleFilter;

    return matchesSearch && matchesStatus && matchesRole;
  });

  // Sorting state
  let sortColumn = '';
  let sortDirection = 'asc';

  $: sortedUsers = [...filteredUsers].sort((a, b) => {
    if (!sortColumn) return 0;

    const aValue = a[sortColumn];
    const bValue = b[sortColumn];

    if (typeof aValue === 'string') {
      return sortDirection === 'asc' 
        ? aValue.localeCompare(bValue)
        : bValue.localeCompare(aValue);
    }

    return sortDirection === 'asc' ? aValue - bValue : bValue - aValue;
  });

  // Pagination calculations
  $: totalPages = Math.ceil(sortedUsers.length / pageSize);
  $: startIndex = ($currentPage - 1) * pageSize;
  $: endIndex = startIndex + pageSize;
  $: paginatedUsers = sortedUsers.slice(startIndex, endIndex);

  // Table configuration
  const table = createTable({
    data: paginatedUsers,
    columns: [
      { 
        id: 'name', 
        header: 'Name', 
        accessor: (row) => row.name,
        sortable: true
      },
      { 
        id: 'email', 
        header: 'Email', 
        accessor: (row) => row.email,
        sortable: true
      },
      { 
        id: 'role', 
        header: 'Role', 
        accessor: (row) => row.role,
        sortable: true
      },
      { 
        id: 'status', 
        header: 'Status', 
        accessor: (row) => row.status,
        sortable: true
      },
      { 
        id: 'joinDate', 
        header: 'Join Date', 
        accessor: (row) => new Date(row.joinDate).toLocaleDateString(),
        sortable: true
      }
    ]
  });

  // Sorting handler
  function handleSort(columnId) {
    if (sortColumn === columnId) {
      sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
    } else {
      sortColumn = columnId;
      sortDirection = 'asc';
    }
    $currentPage = 1; // Reset to first page when sorting
  }

  // Get sort indicator
  function getSortIndicator(columnId) {
    if (sortColumn !== columnId) return '↕️';
    return sortDirection === 'asc' ? '' : '';
  }

  // Pagination handlers
  function goToPage(page) {
    if (page >= 1 && page <= totalPages) {
      currentPage.set(page);
    }
  }

  function previousPage() {
    if ($currentPage > 1) {
      currentPage.update(n => n - 1);
    }
  }

  function nextPage() {
    if ($currentPage < totalPages) {
      currentPage.update(n => n + 1);
    }
  }

  // Reset filters
  function resetFilters() {
    searchQuery = '';
    statusFilter = 'all';
    roleFilter = 'all';
    $currentPage = 1;
  }
</script>

<div class="data-table-container">
  <div class="table-header">
    <h2 class="table-title">User Management</h2>
    <div class="table-actions">
      <button class="btn btn-secondary" on:click={resetFilters}>
        Reset Filters
      </button>
    </div>
  </div>

  <!-- Filters -->
  <div class="filters">
    <div class="filter-group">
      <label for="search">Search:</label>
      <input
        id="search"
        type="text"
        placeholder="Search by name or email..."
        bind:value={searchQuery}
        class="filter-input"
      />
    </div>

    <div class="filter-group">
      <label for="status">Status:</label>
      <select id="status" bind:value={statusFilter} class="filter-select">
        <option value="all">All</option>
        <option value="Active">Active</option>
        <option value="Inactive">Inactive</option>
      </select>
    </div>

    <div class="filter-group">
      <label for="role">Role:</label>
      <select id="role" bind:value={roleFilter} class="filter-select">
        <option value="all">All</option>
        <option value="Admin">Admin</option>
        <option value="Moderator">Moderator</option>
        <option value="User">User</option>
      </select>
    </div>
  </div>

  <!-- Table -->
  <div class="table-wrapper">
    <table class="data-table" role="table">
      <thead>
        <tr>
          {#each $table.columns as column}
            <th
              role="columnheader"
              class={column.sortable ? 'sortable' : ''}
              on:click={column.sortable ? () => handleSort(column.id) : undefined}
              tabindex={column.sortable ? 0 : undefined}
              on:keydown={(e) => column.sortable && e.key === 'Enter' && handleSort(column.id)}
            >
              <span class="header-content">
                {column.header}
                {#if column.sortable}
                  <span class="sort-indicator">{getSortIndicator(column.id)}</span>
                {/if}
              </span>
            </th>
          {/each}
        </tr>
      </thead>
      <tbody>
        {#if paginatedUsers.length === 0}
          <tr>
            <td colspan={$table.columns.length} class="empty-state">
              No users found matching your criteria.
            </td>
          </tr>
        {:else}
          {#each paginatedUsers as user (user.id)}
            <tr>
              <td>{user.name}</td>
              <td>{user.email}</td>
              <td>
                <span class="badge badge-{user.role.toLowerCase()}">{user.role}</span>
              </td>
              <td>
                <span class="status status-{user.status.toLowerCase()}">{user.status}</span>
              </td>
              <td>{new Date(user.joinDate).toLocaleDateString()}</td>
            </tr>
          {/each}
        {/if}
      </tbody>
    </table>
  </div>

  <!-- Pagination -->
  {#if totalPages > 1}
    <div class="pagination">
      <div class="pagination-info">
        Showing {startIndex + 1} to {Math.min(endIndex, sortedUsers.length)} of {sortedUsers.length} users
      </div>
      <div class="pagination-controls">
        <button
          class="btn btn-pagination"
          on:click={previousPage}
          disabled={$currentPage === 1}
          aria-label="Previous page"
        >
          Previous
        </button>

        {#each Array(totalPages) as _, i}
          {@const page = i + 1}
          {#if page === 1 || page === totalPages || (page >= $currentPage - 1 && page <= $currentPage + 1)}
            <button
              class="btn btn-pagination"
              class:active={$currentPage === page}
              on:click={() => goToPage(page)}
              aria-label="Page {page}"
              aria-current={$currentPage === page ? 'page' : undefined}
            >
              {page}
            </button>
          {:else if page === $currentPage - 2 || page === $currentPage + 2}
            <span class="pagination-ellipsis">...</span>
          {/if}
        {/each}

        <button
          class="btn btn-pagination"
          on:click={nextPage}
          disabled={$currentPage === totalPages}
          aria-label="Next page"
        >
          Next
        </button>
      </div>
    </div>
  {/if}
</div>

<style>
  .data-table-container {
    width: 100%;
    max-width: 1200px;
    margin: 0 auto;
    padding: 1.5rem;
    font-family: system-ui, -apple-system, sans-serif;
  }

  .table-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 1.5rem;
  }

  .table-title {
    font-size: 1.5rem;
    font-weight: 600;
    color: #111827;
    margin: 0;
  }

  .filters {
    display: flex;
    gap: 1rem;
    margin-bottom: 1.5rem;
    flex-wrap: wrap;
  }

  .filter-group {
    display: flex;
    flex-direction: column;
    gap: 0.25rem;
    flex: 1;
    min-width: 150px;
  }

  .filter-group label {
    font-size: 0.875rem;
    font-weight: 500;
    color: #374151;
  }

  .filter-input,
  .filter-select {
    padding: 0.5rem;
    border: 1px solid #d1d5db;
    border-radius: 0.375rem;
    font-size: 0.875rem;
  }

  .filter-input:focus,
  .filter-select:focus {
    outline: none;
    border-color: #3b82f6;
    box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
  }

  .table-wrapper {
    overflow-x: auto;
    border: 1px solid #e5e7eb;
    border-radius: 0.5rem;
  }

  .data-table {
    width: 100%;
    border-collapse: collapse;
    background: white;
  }

  .data-table thead {
    background-color: #f9fafb;
  }

  .data-table th {
    padding: 0.75rem 1rem;
    text-align: left;
    font-weight: 600;
    font-size: 0.875rem;
    color: #374151;
    border-bottom: 2px solid #e5e7eb;
  }

  .data-table th.sortable {
    cursor: pointer;
    user-select: none;
  }

  .data-table th.sortable:hover {
    background-color: #f3f4f6;
  }

  .data-table th.sortable:focus {
    outline: 2px solid #3b82f6;
    outline-offset: -2px;
  }

  .header-content {
    display: flex;
    align-items: center;
    gap: 0.5rem;
  }

  .sort-indicator {
    font-size: 0.75rem;
    color: #6b7280;
  }

  .data-table td {
    padding: 0.75rem 1rem;
    border-bottom: 1px solid #e5e7eb;
    font-size: 0.875rem;
    color: #111827;
  }

  .data-table tbody tr:hover {
    background-color: #f9fafb;
  }

  .data-table tbody tr:last-child td {
    border-bottom: none;
  }

  .empty-state {
    text-align: center;
    padding: 3rem 1rem;
    color: #6b7280;
  }

  .badge {
    display: inline-block;
    padding: 0.25rem 0.75rem;
    border-radius: 9999px;
    font-size: 0.75rem;
    font-weight: 500;
    text-transform: capitalize;
  }

  .badge-admin {
    background-color: #fef3c7;
    color: #92400e;
  }

  .badge-moderator {
    background-color: #dbeafe;
    color: #1e40af;
  }

  .badge-user {
    background-color: #e5e7eb;
    color: #374151;
  }

  .status {
    display: inline-block;
    padding: 0.25rem 0.75rem;
    border-radius: 9999px;
    font-size: 0.75rem;
    font-weight: 500;
    text-transform: capitalize;
  }

  .status-active {
    background-color: #d1fae5;
    color: #065f46;
  }

  .status-inactive {
    background-color: #fee2e2;
    color: #991b1b;
  }

  .pagination {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-top: 1.5rem;
    padding-top: 1rem;
    border-top: 1px solid #e5e7eb;
  }

  .pagination-info {
    font-size: 0.875rem;
    color: #6b7280;
  }

  .pagination-controls {
    display: flex;
    gap: 0.5rem;
    align-items: center;
  }

  .btn {
    padding: 0.5rem 1rem;
    border: 1px solid #d1d5db;
    border-radius: 0.375rem;
    background: white;
    font-size: 0.875rem;
    font-weight: 500;
    cursor: pointer;
    transition: all 0.2s;
  }

  .btn:hover:not(:disabled) {
    background-color: #f9fafb;
    border-color: #9ca3af;
  }

  .btn:disabled {
    opacity: 0.5;
    cursor: not-allowed;
  }

  .btn-secondary {
    background-color: #f3f4f6;
    color: #374151;
  }

  .btn-pagination {
    min-width: 2.5rem;
    padding: 0.5rem 0.75rem;
  }

  .btn-pagination.active {
    background-color: #3b82f6;
    color: white;
    border-color: #3b82f6;
  }

  .btn-pagination.active:hover {
    background-color: #2563eb;
  }

  .pagination-ellipsis {
    padding: 0 0.5rem;
    color: #6b7280;
  }

  @media (max-width: 768px) {
    .filters {
      flex-direction: column;
    }

    .filter-group {
      min-width: 100%;
    }

    .pagination {
      flex-direction: column;
      gap: 1rem;
    }
  }
</style>
Enter fullscreen mode Exit fullscreen mode

This complete data table includes:

  • Search functionality: Filter by name or email
  • Multi-filter support: Filter by status and role
  • Column sorting: Click headers to sort ascending/descending
  • Pagination: Navigate through pages of results
  • Responsive design: Works on mobile and desktop
  • Accessibility: Proper ARIA labels and keyboard navigation
  • Empty states: Shows message when no data matches filters
  • Visual indicators: Badges for roles and status

Common Issues / Troubleshooting

Table not updating when data changes

  • Ensure you're using reactive statements ($:) for computed values
  • Update the table instance when data changes: table.update({ data: newData })
  • Check that your data array is being reassigned, not mutated

Sorting not working correctly

  • Verify that sortColumn matches the column id exactly
  • Check that your accessor function returns comparable values
  • Ensure you're creating a new array when sorting: [...filteredUsers].sort()

Pagination showing wrong page

  • Make sure to reset currentPage to 1 when filters change
  • Verify that startIndex and endIndex calculations are correct
  • Check that totalPages is calculated before pagination

Performance issues with large datasets

  • Consider implementing virtual scrolling for very large datasets
  • Use keyed each blocks: {#each items as item (item.id)}
  • Debounce search input to avoid filtering on every keystroke

Next Steps

Now that you have a working data table, consider:

  • Server-side pagination: Load data from API in chunks instead of all at once
  • Advanced filtering: Add date range filters, multi-select filters, or custom filter components
  • Column customization: Allow users to show/hide columns or reorder them
  • Export functionality: Add CSV/Excel export for table data
  • Row selection: Implement checkboxes for bulk actions
  • Inline editing: Allow editing cells directly in the table
  • Virtual scrolling: For tables with thousands of rows

For more information, visit the AgnosUI documentation and explore other components like Select, DatePicker, and Dialog for enhanced table interactions.

Summary

You've learned how to build an interactive data table using AgnosUI components in Svelte. The table includes sorting, filtering, pagination, and proper accessibility features. You can now create production-ready data tables that work seamlessly with Svelte's reactivity system while maintaining full control over styling and behavior.

Top comments (0)