Module 13: Data Tables & Grids

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.

Module Files

1. The Analytics Table Use Case

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:

ArchetypePurposeExample
Raw LogInspect individual records as collectedEvery pageview with timestamp, URL, CWV metrics
Aggregated SummaryGroup and count/average dataPageviews per page with avg LCP, total sessions
Drill-Down DetailRecords behind a chart segmentClick "/pricing" in doughnut → see its 25 pageviews

The relationship between charts and tables follows a consistent interaction pattern:

Aggregated Chart → click segment → Detail Table → download → CSV / JSON (pattern) (drill-down) (individual) (export) (verification)

This is Shneiderman's mantra in action: overview first (chart), zoom and filter (click segment), then details on demand (table).

2. Semantic HTML Tables

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:

Anti-Pattern: <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.

3. Client-Side Sorting

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.

Type-Aware Comparator

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

Toggling Sort Direction

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();
}

ARIA Sort Indicator

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

See Sorting in Action →

4. Filtering and Search

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)
        )
    );
}

Debouncing

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
});

Result Count Announcement

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 →

5. Pagination

Analytics tables often contain hundreds or thousands of records. Three strategies handle large datasets:

StrategyDOM WeightUXImplementation
PaginationLow (pageSize rows)Predictable; user knows positionSimple: slice array
Load MoreGrows over timeContinuous scroll feelModerate: append rows
Virtual ScrollingFixed (visible rows + buffer)Seamless scrollingComplex: 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 →

6. Virtual Scrolling

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.

Virtual Scrolling Breaks Ctrl+F: Only visible rows exist in the DOM. The browser's native Find (Ctrl+F / Cmd+F) cannot search off-screen rows. This is why analytics dashboards often prefer pagination with a search box — search is handled by the application, not the browser. If you implement virtual scrolling, make sure your application-level search is prominent.

7. Column Management

Analytics tables often have 10–15+ columns. Users need to focus on relevant columns and hide the rest. Two common patterns:

Column Visibility Toggles

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);

Frozen First Column

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 →

8. Conditional Cell Styling

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.

MetricGood (green)Needs Improvement (amber)Poor (red)
LCP≤ 2,500 ms2,500–4,000 ms> 4,000 ms
CLS≤ 0.10.1–0.25> 0.25
INP≤ 200 ms200–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 →

9. Chart-to-Table Drill-Down

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.

Implementation Pattern

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

Drill-Down Is Progressive Disclosure: Shneiderman's visual information-seeking mantra — "overview first, zoom and filter, then details on demand" — maps directly to the chart-then-table pattern. The chart provides the overview, clicking zooms and filters, and the table provides details. This is the foundational interaction pattern of every analytics dashboard.
See Drill-Down in Action →

10. Grid Components

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:

LibraryApproachBundle SizeREST SupportLearning Curve
Vanilla JSImperative (manual)0 KBManual fetch()Low (you wrote it)
ZingGridWeb component (declarative HTML)~200 KBsrc attributeLow
AG GridConfig object~300 KBDatasource interfaceModerate
TanStack TableHeadless (logic only)~50 KBManualModerate

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.

11. ZingGrid for Analytics

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.

ZingGrid renders semantic <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.
See ZingGrid Demo →

12. Light Editing for Data Cleanup

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>
Analytics data is append-only. Editing raw records (changing a timestamp, modifying a CWV value) is almost never correct. The primary use case for table editing in analytics is metadata — labels, tags, and classifications that are stored separately from the raw event data.

13. Summary

Key takeaways from this module: