Charts show patterns; tables show values. A complete analytics dashboard provides both. This module covers interactive data tables — the complement to every chart you have built so far.
In analytics, tables are read-only inspection tools. You are not building a spreadsheet for data entry — you are building a viewer that lets analysts drill into the data behind charts. There are three archetypal table views in an analytics dashboard:
| Archetype | Purpose | Example |
|---|---|---|
| Raw Log | Inspect individual records as collected | Every pageview with timestamp, URL, CWV metrics |
| Aggregated Summary | Group and count/average data | Pageviews per page with avg LCP, total sessions |
| Drill-Down Detail | Records behind a chart segment | Click "/pricing" in doughnut → see its 25 pageviews |
The relationship between charts and tables follows a consistent interaction pattern:
This is Shneiderman's mantra in action: overview first (chart), zoom and filter (click segment), then details on demand (table).
Every interactive grid starts as a semantic HTML <table>. The structure matters for both accessibility and progressive enhancement:
<table>
<caption>Pageviews — January 2026</caption>
<thead>
<tr>
<th scope="col">URL</th>
<th scope="col">Time</th>
<th scope="col">LCP (ms)</th>
<th scope="col">CLS</th>
</tr>
</thead>
<tbody>
<tr>
<td>/home</td>
<td>Jan 5, 10:30 AM</td>
<td>1,842</td>
<td>0.045</td>
</tr>
</tbody>
</table>
Key elements:
<caption> — identifies the table for screen readers and sighted users alike<thead> / <tbody> — separates header from data; allows sticky headers<th scope="col"> — associates each header with its column for assistive technology<div> Grids Without ARIA: Some grid libraries replace <table> with nested <div> elements for styling flexibility. Without explicit ARIA roles (role="grid", role="row", role="gridcell"), screen readers cannot navigate the data. Always verify that your grid library produces accessible output.Sorting is the most fundamental table interaction. The user clicks a column header to sort ascending; clicks again for descending. The implementation has three parts: a comparator function, state tracking, and ARIA updates.
function sortData(data, column, direction) {
return [...data].sort((a, b) => {
let va = a[column], vb = b[column];
if (va == null) return 1; // nulls sort last
if (vb == null) return -1;
// Numeric comparison
if (typeof va === 'number' && typeof vb === 'number') {
return direction === 'asc' ? va - vb : vb - va;
}
// String comparison (case-insensitive)
va = String(va).toLowerCase();
vb = String(vb).toLowerCase();
if (va < vb) return direction === 'asc' ? -1 : 1;
if (va > vb) return direction === 'asc' ? 1 : -1;
return 0;
});
}
The [...data] spread creates a copy — never mutate the original data array. The comparator checks typeof to decide between numeric subtraction and string comparison. This handles the common analytics columns: numbers (LCP, CLS), strings (URL, referrer), and ISO timestamps (which sort correctly as strings).
function onSort(columnKey) {
if (sortColumn === columnKey) {
// Same column: toggle direction
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
} else {
// New column: start ascending
sortColumn = columnKey;
sortDirection = 'asc';
}
render();
}
Set aria-sort on the active header so screen readers announce the sort state:
<th scope="col" aria-sort="ascending">LCP (ms) ▲</th> <th scope="col" aria-sort="none">CLS</th>
The visual arrow (▲ / ▼) and the aria-sort attribute must stay in sync. Values: "ascending", "descending", or "none".
A search box filters the table to rows where any visible column contains the query string. This is a global text filter — the simplest and most useful form of table filtering.
function filterData(data, query, columns) {
if (!query) return data;
const q = query.toLowerCase();
return data.filter(row =>
columns.some(col =>
String(row[col]).toLowerCase().includes(q)
)
);
}
Filtering on every keystroke is wasteful for large datasets. Debounce the input event so filtering runs only after the user pauses typing:
let debounceTimer;
searchInput.addEventListener('input', (e) => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
searchQuery = e.target.value.trim();
currentPage = 1; // reset to first page
applyFiltersAndRender();
}, 200); // 200ms debounce
});
When the filter changes the visible row count, announce it to screen readers using an aria-live region:
<div aria-live="polite" id="statusBar"></div>
// After filtering:
statusBar.textContent = `${filtered.length} records match "${query}"`;
See Filtering in Action →
Analytics tables often contain hundreds or thousands of records. Three strategies handle large datasets:
| Strategy | DOM Weight | UX | Implementation |
|---|---|---|---|
| Pagination | Low (pageSize rows) | Predictable; user knows position | Simple: slice array |
| Load More | Grows over time | Continuous scroll feel | Moderate: append rows |
| Virtual Scrolling | Fixed (visible rows + buffer) | Seamless scrolling | Complex: scroll math |
For analytics dashboards, pagination is the best default. It keeps the DOM small, gives users a clear sense of position, and is trivial to implement:
function paginateData(data, page, pageSize) {
const totalRows = data.length;
const totalPages = Math.max(1, Math.ceil(totalRows / pageSize));
const safePage = Math.max(1, Math.min(page, totalPages));
const start = (safePage - 1) * pageSize;
return {
rows: data.slice(start, start + pageSize),
page: safePage,
totalPages,
totalRows
};
}
The pagination UI needs: page indicator, prev/next buttons, and a page-size selector. Reset to page 1 whenever the filter or sort changes.
See Pagination in Action →Virtual scrolling renders only the rows visible in the viewport, plus a small buffer above and below. A spacer <div> maintains the scroll height so the scrollbar reflects the true dataset size.
The core formula:
const startIndex = Math.floor(scrollTop / rowHeight); const visibleCount = Math.ceil(containerHeight / rowHeight); const endIndex = Math.min(startIndex + visibleCount + buffer, totalRows); // Render only rows[startIndex..endIndex] // Set spacer height = totalRows * rowHeight
Virtual scrolling handles 10K+ rows without DOM explosion. It is the right choice when the user needs to scroll freely through a large result set without pagination breaks.
Analytics tables often have 10–15+ columns. Users need to focus on relevant columns and hide the rest. Two common patterns:
Checkbox group above the table — each checkbox shows/hides a column. Store visibility in the column definition array:
const COLUMNS = [
{ key: 'url', label: 'URL', visible: true },
{ key: 'lcp', label: 'LCP (ms)', visible: true },
{ key: 'referrer', label: 'Referrer', visible: false },
{ key: 'session_id', label: 'Session', visible: false }
];
// Filter to visible columns before rendering
const visibleCols = COLUMNS.filter(c => c.visible);
When scrolling horizontally through many columns, the URL (first column) should stay visible. CSS position: sticky handles this without JavaScript:
thead th:first-child,
tbody td:first-child {
position: sticky;
left: 0;
z-index: 1;
background: white; /* prevent content showing through */
}
See Column Management in Action →
Core Web Vitals have defined thresholds: good, needs improvement, and poor. Applying color to cells based on these thresholds turns the table into a visual diagnostic tool.
| Metric | Good (green) | Needs Improvement (amber) | Poor (red) |
|---|---|---|---|
| LCP | ≤ 2,500 ms | 2,500–4,000 ms | > 4,000 ms |
| CLS | ≤ 0.1 | 0.1–0.25 | > 0.25 |
| INP | ≤ 200 ms | 200–500 ms | > 500 ms |
function formatCell(value, column) {
const thresholds = {
lcp: { good: 2500, poor: 4000 },
cls: { good: 0.1, poor: 0.25 },
inp: { good: 200, poor: 500 }
};
const t = thresholds[column];
if (!t) return { text: value, className: '' };
let className = 'cwv-good';
if (value > t.poor) className = 'cwv-poor';
else if (value > t.good) className = 'cwv-needs-improvement';
return { text: column === 'cls' ? value.toFixed(3) : Math.round(value), className };
}
// CSS classes
.cwv-good { background: #e8f5e9; color: #2e7d32; }
.cwv-needs-improvement { background: #fff3e0; color: #e65100; }
.cwv-poor { background: #ffebee; color: #c62828; }
This heatmap-style coloring lets analysts spot problem pages instantly without reading every number.
See CWV Styling in Action →The drill-down pattern is the most important interactive connection in an analytics dashboard. A user sees an aggregated chart, clicks a segment, and the table below filters to show the individual records behind that aggregate.
// Chart.js onClick handler
options: {
onClick: (event, elements) => {
if (elements.length > 0) {
const idx = elements[0].index;
const clickedLabel = chart.data.labels[idx];
drillDown(clickedLabel);
}
}
}
// Drill-down: filter table to matching records
function drillDown(url) {
filteredData = allData.filter(r => r.url === url);
currentPage = 1;
renderTable();
showFilterBadge(url);
}
// Reset
function clearFilter() {
filteredData = allData;
renderTable();
hideFilterBadge();
}
The filter badge shows which segment is active and provides a "Show All" button to reset. This is progressive disclosure — the table starts showing all data, narrows when the user asks, and resets when they are done.
Building sort, filter, pagination, column management, and conditional styling from scratch takes ~300 lines of JavaScript (see sortable-table.html). For production dashboards, grid component libraries provide these features declaratively:
| Library | Approach | Bundle Size | REST Support | Learning Curve |
|---|---|---|---|---|
| Vanilla JS | Imperative (manual) | 0 KB | Manual fetch() | Low (you wrote it) |
| ZingGrid | Web component (declarative HTML) | ~200 KB | src attribute | Low |
| AG Grid | Config object | ~300 KB | Datasource interface | Moderate |
| TanStack Table | Headless (logic only) | ~50 KB | Manual | Moderate |
The choice mirrors the declarative vs. imperative charting decision from the overview. Vanilla JS gives full understanding and control; a library gives speed and features. Start vanilla to learn the patterns, then adopt a library when the feature set exceeds what you want to maintain.
ZingGrid is a web component — a custom HTML element that encapsulates grid functionality. Features are enabled via HTML attributes:
<zing-grid
src="sample-pageviews.json"
pager page-size="25"
sort filter search
caption="Pageviews — January 2026">
</zing-grid>
That is the entire implementation. Six attributes replace 300+ lines of vanilla JavaScript. Column definitions, custom renderers, and layout options add a few more lines:
<zing-grid src="sample-pageviews.json" pager sort filter search>
<zg-colgroup>
<zg-column index="url" header="URL"></zg-column>
<zg-column index="lcp" header="LCP (ms)" type="number"
renderer="renderLCP"></zg-column>
<zg-column index="cls" header="CLS" type="number"
renderer="renderCLS"></zg-column>
<zg-column index="referrer" header="Referrer" hidden></zg-column>
</zg-colgroup>
</zing-grid>
The renderer attribute points to a JavaScript function that returns HTML for each cell — this is how you apply the CWV threshold coloring from Section 8.
<table> markup with ARIA roles by default. Unlike many grid libraries that use <div>-based layouts, ZingGrid outputs real <table>, <tr>, and <td> elements. This means the accessibility baseline from Section 2 is maintained without additional work.Analytics data is fundamentally append-only — you do not edit a pageview record after it is collected. However, there are narrow cases where light editing is useful:
For simple cases, HTML contenteditable or a click-to-input pattern works. For ZingGrid, the editor attribute enables inline editing:
<zing-grid src="/api/events" editor="inline">
<zg-colgroup>
<zg-column index="label" header="Label" editor="text"></zg-column>
<zg-column index="category" header="Category" editor="select"
editor-select-options="internal,external,bot"></zg-column>
</zg-colgroup>
</zing-grid>
Key takeaways from this module:
<table>, <caption>, <th scope> — the accessibility baseline is the foundation for all interactivity.aria-sort), filtering (global text search, debounce, aria-live), pagination (array slice, page controls).