(function () {
var root = document.querySelector('[data-dt]');
if (!root) return;
// Your rows. Swap this array for your own data, or fill it from fetch() and call render().
var DATA = [
{num:'INV-1041',client:'Arcadia Labs',email:'[email protected]',amount:2400,date:'2026-06-28',status:'Paid'},
{num:'INV-1040',client:'Fieldnote',email:'[email protected]',amount:860,date:'2026-06-26',status:'Pending'},
{num:'INV-1039',client:'Northwind',email:'[email protected]',amount:5300,date:'2026-06-21',status:'Paid'},
{num:'INV-1038',client:'Corvid Analytics',email:'[email protected]',amount:1275,date:'2026-06-18',status:'Overdue'},
{num:'INV-1037',client:'Meridian',email:'[email protected]',amount:990,date:'2026-06-15',status:'Paid'},
{num:'INV-1036',client:'Bluewater Co',email:'[email protected]',amount:3150,date:'2026-06-11',status:'Pending'},
{num:'INV-1035',client:'Hexagon Press',email:'[email protected]',amount:410,date:'2026-06-07',status:'Paid'},
{num:'INV-1034',client:'Solstice',email:'[email protected]',amount:2790,date:'2026-06-03',status:'Overdue'},
{num:'INV-1033',client:'Papergrain',email:'[email protected]',amount:1580,date:'2026-05-29',status:'Paid'},
{num:'INV-1032',client:'Vantage Point',email:'[email protected]',amount:720,date:'2026-05-24',status:'Paid'},
{num:'INV-1031',client:'Driftwood',email:'[email protected]',amount:1940,date:'2026-05-20',status:'Pending'},
{num:'INV-1030',client:'Lumen Works',email:'[email protected]',amount:4460,date:'2026-05-16',status:'Paid'}
];
var PER = 5, page = 1, sortK = 'num', sortDir = -1, query = '';
var tbody = root.querySelector('tbody'), count = root.querySelector('[data-dt-count]');
var info = root.querySelector('[data-dt-info]'), pages = root.querySelector('[data-dt-pages]');
var heads = Array.prototype.slice.call(root.querySelectorAll('th[data-k]'));
function money(n) { return '$' + n.toLocaleString('en-US'); }
var PAGE_BTN = 'h-8 min-w-8 cursor-pointer rounded-lg border border-[#d6dce7] bg-white px-2 text-[.82rem] font-semibold text-[#3c4658] transition-colors duration-150 hover:bg-[#f1f5fb] ' +
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#3b82f6] disabled:cursor-default disabled:opacity-40 disabled:hover:bg-white ' +
'aria-[current=page]:border-[#1a2233] aria-[current=page]:bg-[#1a2233] aria-[current=page]:text-white motion-reduce:transition-none';
var BADGE = { Paid: 'bg-[#e7f8ee] text-[#177a45]', Pending: 'bg-[#fff4e0] text-[#8a5d0c]', Overdue: 'bg-[#fdeaea] text-[#b42323]' };
function pageBtn(label, target, opts) {
return '<button type="button" class="' + PAGE_BTN + '" data-page="' + target + '"' + (opts.aria ? ' aria-label="' + opts.aria + '"' : '') +
(opts.current ? ' aria-current="page"' : '') + (opts.disabled ? ' disabled' : '') + '>' + label + '</button>';
}
function render() {
var rows = DATA.filter(function (r) { return (r.client + ' ' + r.num).toLowerCase().indexOf(query) > -1; });
rows.sort(function (a, b) { return (a[sortK] > b[sortK] ? 1 : a[sortK] < b[sortK] ? -1 : 0) * sortDir; });
var total = rows.length, maxPage = Math.max(1, Math.ceil(total / PER));
if (page > maxPage) page = maxPage;
tbody.innerHTML = rows.slice((page - 1) * PER, page * PER).map(function (r) {
return '<tr class="hover:bg-[#f8fafd]"><th scope="row" class="font-normal">' + r.num + '</th><td class="font-semibold">' + r.client +
'<small class="block text-[.83em] font-normal text-[#667189]">' + r.email + '</small></td><td>' + money(r.amount) + '</td><td>' + r.date +
'</td><td><span class="rounded-full px-2.5 py-1 text-[.72rem] font-bold whitespace-nowrap ' + BADGE[r.status] + '">' + r.status + '</span></td></tr>';
}).join('') || '<tr><td colspan="5" class="p-[26px]! text-center text-[#667189]">No matches.</td></tr>';
count.textContent = total + ' total';
info.textContent = total ? 'Showing ' + ((page - 1) * PER + 1) + '–' + Math.min(page * PER, total) + ' of ' + total : 'No matches';
var html = pageBtn('‹', page - 1, { aria: 'Previous page', disabled: page === 1 });
for (var i = 1; i <= maxPage; i++) html += pageBtn(i, i, { aria: 'Page ' + i, current: i === page });
pages.innerHTML = html + pageBtn('›', page + 1, { aria: 'Next page', disabled: page === maxPage });
}
heads.forEach(function (th) {
th.querySelector('button').addEventListener('click', function () {
var k = th.getAttribute('data-k');
if (sortK === k) sortDir *= -1; else { sortK = k; sortDir = 1; }
heads.forEach(function (h) { h.removeAttribute('aria-sort'); });
th.setAttribute('aria-sort', sortDir === 1 ? 'ascending' : 'descending');
render();
});
});
pages.addEventListener('click', function (e) {
var b = e.target.closest('button[data-page]');
if (!b || b.disabled) return;
page = Number(b.getAttribute('data-page'));
render();
pages.querySelector('[aria-current="page"]').focus(); // the clicked button was replaced; keep focus in the pager
});
root.querySelector('[data-dt-search]').addEventListener('input', function (e) { query = e.target.value.trim().toLowerCase(); page = 1; render(); });
render();
})();
About this set
A complete invoice datatable with no library behind it: a live search box, five sortable columns and numbered pagination, all driven by about sixty lines of vanilla JavaScript. The rows live in a plain array at the top of the script, so you can paste in your own records or fill the array from fetch() and call render() again; search, sort order and paging keep working. Searching matches the client name and the invoice number and resets to page one. Each column header is a real button inside a th, and the sorted column carries aria-sort ascending or descending while a small arrow icon shows the direction. The pager is a nav with Previous, numbered and Next buttons; the current page has aria-current and focus stays on the pager after a page change. A polite live region reads out which rows are showing. Use it for admin screens with a few hundred rows that you already have in the browser; for server-side data keep the markup and replace the filter and slice lines with a request. The Bootstrap version uses .form-control, .table-hover, .badge and .pagination.
What’s included
Live search across client name and invoice number
Five sortable columns with aria-sort on the active header
Numbered pagination with Previous and Next, current page marked with aria-current
Rows rendered from a plain JavaScript array, easy to swap for your data
Horizontally scrollable, keyboard-focusable table region on narrow screens
Accessibility
The table is labelled by its heading and every header is a scope="col" th whose sort control is a button; the sorted column reports aria-sort. Pager buttons have names such as Page 2, the current one has aria-current="page", and a polite live region announces the visible range. All controls show a visible focus outline and transitions switch off under prefers-reduced-motion.
Customise it
Change the colours in the .dt rules (header grey #67718a, accent #2563eb, pager #1a2233) and PER in the script for rows per page. In Bootstrap the table and pager colours are --bs-table-* and --bs-pagination-* variables on .dt; in Tailwind edit the colour utilities in the markup and the PAGE_BTN and BADGE strings in the script.