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
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>
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
});
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>
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.columnsand$table.rowsto 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>
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>
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>
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
sortColumnmatches the columnidexactly - 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
currentPageto 1 when filters change - Verify that
startIndexandendIndexcalculations are correct - Check that
totalPagesis calculated before pagination
Performance issues with large datasets
- Consider implementing virtual scrolling for very large datasets
- Use
keyed eachblocks:{#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)