Open demo

CSS code

HTML
<section class="dt" data-dt aria-labelledby="dt-title">
  <div class="dt-top">
    <h2><span id="dt-title">Invoices</span><span class="dt-count" data-dt-count></span></h2>
    <input class="dt-search" type="search" data-dt-search placeholder="Search client or number…" aria-label="Search invoices">
  </div>
  <div class="dt-scroll" role="region" tabindex="0" aria-label="Invoices, scrollable">
    <table aria-labelledby="dt-title">
      <thead><tr>
        <th scope="col" data-k="num" aria-sort="descending"><button class="dt-sort" type="button">Invoice <svg class="dt-dir" viewBox="0 0 10 14" aria-hidden="true"><path class="dt-up" d="M5 1 1.5 5.5h7z"/><path class="dt-dn" d="M5 13 1.5 8.5h7z"/></svg></button></th>
        <th scope="col" data-k="client"><button class="dt-sort" type="button">Client <svg class="dt-dir" viewBox="0 0 10 14" aria-hidden="true"><path class="dt-up" d="M5 1 1.5 5.5h7z"/><path class="dt-dn" d="M5 13 1.5 8.5h7z"/></svg></button></th>
        <th scope="col" data-k="amount"><button class="dt-sort" type="button">Amount <svg class="dt-dir" viewBox="0 0 10 14" aria-hidden="true"><path class="dt-up" d="M5 1 1.5 5.5h7z"/><path class="dt-dn" d="M5 13 1.5 8.5h7z"/></svg></button></th>
        <th scope="col" data-k="date"><button class="dt-sort" type="button">Issued <svg class="dt-dir" viewBox="0 0 10 14" aria-hidden="true"><path class="dt-up" d="M5 1 1.5 5.5h7z"/><path class="dt-dn" d="M5 13 1.5 8.5h7z"/></svg></button></th>
        <th scope="col" data-k="status"><button class="dt-sort" type="button">Status <svg class="dt-dir" viewBox="0 0 10 14" aria-hidden="true"><path class="dt-up" d="M5 1 1.5 5.5h7z"/><path class="dt-dn" d="M5 13 1.5 8.5h7z"/></svg></button></th>
      </tr></thead>
      <tbody></tbody>
    </table>
  </div>
  <div class="dt-foot"><span data-dt-info aria-live="polite"></span><nav aria-label="Invoice pages"><div class="dt-pages" data-dt-pages></div></nav></div>
</section>
CSS
/* Vanilla JS datatable: live search, sortable columns (header buttons with aria-sort) and numbered pagination.
   Rows come from a plain array in the script, so swapping in your data or a fetch() keeps everything working. */
.dt{width:100%;max-width:820px;background:#fff;color:#1a2233;border:1px solid #e6eaf1;border-radius:16px;padding:24px;box-shadow:0 14px 34px -18px rgba(20,28,45,.16)}
.dt-top{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:18px;flex-wrap:wrap}
.dt-top h2{font-size:1.05rem;font-weight:700;letter-spacing:-.01em;margin:0}
.dt-count{color:#667189;font-weight:500;font-size:.85rem;margin-left:8px}
.dt-search{padding:9px 14px;border:1px solid #8a94a6;border-radius:10px;font:inherit;font-size:.88rem;width:230px;max-width:100%;transition:border-color .15s,box-shadow .15s}
.dt-search:focus{outline:none;border-color:#3b82f6;box-shadow:0 0 0 3px rgba(59,130,246,.25)}
.dt-scroll{position:relative;overflow-x:auto}
.dt-scroll:focus-visible{outline:2px solid #3b82f6;outline-offset:2px}
.dt table{width:100%;border-collapse:collapse;font-size:.88rem}
.dt th,.dt td{text-align:left}
.dt thead th{font-size:.73rem;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:#67718a;padding:11px 14px;border-bottom:2px solid #eef1f7;white-space:nowrap}
.dt-sort{display:inline-flex;align-items:center;gap:5px;padding:0;border:0;background:none;font:inherit;letter-spacing:inherit;text-transform:inherit;color:inherit;cursor:pointer;border-radius:4px}
.dt-sort:hover{color:#1a2233}
.dt-sort:focus-visible{outline:2px solid #3b82f6;outline-offset:3px}
.dt-dir{width:8px;height:11px;fill:currentColor;opacity:.45}
.dt th[aria-sort] .dt-dir{opacity:1;color:#2563eb}
.dt th[aria-sort="ascending"] .dt-dn,.dt th[aria-sort="descending"] .dt-up{display:none}
.dt tbody th,.dt td{padding:12px 14px;border-bottom:1px solid #f1f4f9;font-weight:400}
.dt tbody tr:hover{background:#f8fafd}
.dt .dt-who{font-weight:600}
.dt-who small{display:block;font-size:.83em;font-weight:400;color:#667189}
.dt-badge{font-size:.72rem;font-weight:700;padding:4px 10px;border-radius:999px;white-space:nowrap}
.dt-paid{background:#e7f8ee;color:#177a45}.dt-pending{background:#fff4e0;color:#8a5d0c}.dt-overdue{background:#fdeaea;color:#b42323}
.dt-empty{text-align:center!important;color:#667189;padding:26px!important}
.dt-foot{display:flex;align-items:center;justify-content:space-between;margin-top:16px;font-size:.82rem;color:#67718a;flex-wrap:wrap;gap:12px}
.dt-pages{display:flex;gap:6px;flex-wrap:wrap}
.dt-pages button{min-width:32px;height:32px;border:1px solid #d6dce7;background:#fff;border-radius:8px;font:inherit;font-size:.82rem;font-weight:600;color:#3c4658;cursor:pointer;transition:background-color .15s}
.dt-pages button:hover{background:#f1f5fb}
.dt-pages button[aria-current="page"]{background:#1a2233;border-color:#1a2233;color:#fff}
.dt-pages button:disabled{opacity:.4;cursor:default}
.dt-pages button:focus-visible{outline:2px solid #3b82f6;outline-offset:2px}
@media (prefers-reduced-motion:reduce){.dt-search,.dt-pages button{transition:none}}
JavaScript
(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'); }
  function pageBtn(label, target, opts) {
    return '<button type="button" 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><th scope="row">' + r.num + '</th><td class="dt-who">' + r.client + '<small>' + r.email + '</small></td><td>' + money(r.amount) +
        '</td><td>' + r.date + '</td><td><span class="dt-badge dt-' + r.status.toLowerCase() + '">' + r.status + '</span></td></tr>';
    }).join('') || '<tr><td colspan="5" class="dt-empty">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();
})();

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS

HTML
<section class="dt w-100" data-dt aria-labelledby="dt-title">
  <div class="dt-top d-flex align-items-center justify-content-between flex-wrap gap-3 mb-3">
    <h2><span id="dt-title">Invoices</span><span class="dt-count" data-dt-count></span></h2>
    <input class="form-control dt-search" type="search" data-dt-search placeholder="Search client or number…" aria-label="Search invoices">
  </div>
  <div class="dt-scroll table-responsive position-relative" role="region" tabindex="0" aria-label="Invoices, scrollable">
    <table class="table table-hover align-middle mb-0" aria-labelledby="dt-title">
      <thead><tr>
        <th scope="col" data-k="num" aria-sort="descending"><button class="dt-sort" type="button">Invoice <svg class="dt-dir" viewBox="0 0 10 14" aria-hidden="true"><path class="dt-up" d="M5 1 1.5 5.5h7z"/><path class="dt-dn" d="M5 13 1.5 8.5h7z"/></svg></button></th>
        <th scope="col" data-k="client"><button class="dt-sort" type="button">Client <svg class="dt-dir" viewBox="0 0 10 14" aria-hidden="true"><path class="dt-up" d="M5 1 1.5 5.5h7z"/><path class="dt-dn" d="M5 13 1.5 8.5h7z"/></svg></button></th>
        <th scope="col" data-k="amount"><button class="dt-sort" type="button">Amount <svg class="dt-dir" viewBox="0 0 10 14" aria-hidden="true"><path class="dt-up" d="M5 1 1.5 5.5h7z"/><path class="dt-dn" d="M5 13 1.5 8.5h7z"/></svg></button></th>
        <th scope="col" data-k="date"><button class="dt-sort" type="button">Issued <svg class="dt-dir" viewBox="0 0 10 14" aria-hidden="true"><path class="dt-up" d="M5 1 1.5 5.5h7z"/><path class="dt-dn" d="M5 13 1.5 8.5h7z"/></svg></button></th>
        <th scope="col" data-k="status"><button class="dt-sort" type="button">Status <svg class="dt-dir" viewBox="0 0 10 14" aria-hidden="true"><path class="dt-up" d="M5 1 1.5 5.5h7z"/><path class="dt-dn" d="M5 13 1.5 8.5h7z"/></svg></button></th>
      </tr></thead>
      <tbody></tbody>
    </table>
  </div>
  <div class="dt-foot d-flex align-items-center justify-content-between flex-wrap gap-3 mt-3"><span data-dt-info aria-live="polite"></span><nav aria-label="Invoice pages"><ul class="pagination mb-0" data-dt-pages></ul></nav></div>
</section>
CSS
/* Bootstrap .form-control, .table-hover, .badge and .pagination, recoloured through their CSS variables.
   The script (no Bootstrap JS needed) does the search, sorting with aria-sort, and paging. */
.dt{max-width:820px;background:#fff;color:#1a2233;border:1px solid #e6eaf1;border-radius:16px;padding:24px;box-shadow:0 14px 34px -18px rgba(20,28,45,.16)}
.dt-top h2{font-size:1.05rem;font-weight:700;letter-spacing:-.01em;line-height:normal;margin:0}
.dt-count{color:#667189;font-weight:500;font-size:.85rem;margin-left:8px}
.dt .dt-search{width:230px;max-width:100%;padding:9px 14px;border-color:#8a94a6;border-radius:10px;font-size:.88rem;line-height:normal}
.dt .dt-search:focus{border-color:#3b82f6;box-shadow:0 0 0 3px rgba(59,130,246,.25)}
.dt-scroll:focus-visible{outline:2px solid #3b82f6;outline-offset:2px}
.dt .table{--bs-table-bg:#fff;--bs-table-color:#1a2233;--bs-table-border-color:#f1f4f9;--bs-table-hover-bg:#f8fafd;--bs-table-hover-color:#1a2233;font-size:.88rem}
.dt .table>:not(caption)>*>*{padding:12px 14px}
.dt .table>thead>tr>th{font-size:.73rem;font-weight:700;letter-spacing:.05em;text-transform:uppercase;--bs-table-color:#67718a;padding:11px 14px;border-bottom:2px solid #eef1f7;white-space:nowrap}
.dt .table>tbody>tr>th{font-weight:400}
.dt-sort{display:inline-flex;align-items:center;gap:5px;padding:0;border:0;background:none;font:inherit;letter-spacing:inherit;text-transform:inherit;color:inherit;border-radius:4px}
.dt-sort:hover{color:#1a2233}
.dt-sort:focus-visible{outline:2px solid #3b82f6;outline-offset:3px}
.dt-dir{width:8px;height:11px;fill:currentColor;opacity:.45}
.dt th[aria-sort] .dt-dir{opacity:1;color:#2563eb}
.dt th[aria-sort="ascending"] .dt-dn,.dt th[aria-sort="descending"] .dt-up{display:none}
.dt-who{font-weight:600}
.dt-who small{display:block;font-size:.83em;font-weight:400;color:#667189}
.dt .badge{--bs-badge-font-size:.72rem;--bs-badge-font-weight:700;--bs-badge-padding-x:10px;--bs-badge-padding-y:4px;display:inline;line-height:normal}
.dt-paid{background:#e7f8ee;color:#177a45}.dt-pending{background:#fff4e0;color:#8a5d0c}.dt-overdue{background:#fdeaea;color:#b42323}
.dt .table>tbody>tr>.dt-empty{--bs-table-color:#667189;padding:26px}
.dt-foot{font-size:.82rem;color:#67718a}
.dt .pagination{--bs-pagination-font-size:.82rem;--bs-pagination-color:#3c4658;--bs-pagination-bg:#fff;--bs-pagination-border-color:#d6dce7;--bs-pagination-border-radius:8px;
  --bs-pagination-hover-color:#3c4658;--bs-pagination-hover-bg:#f1f5fb;--bs-pagination-hover-border-color:#d6dce7;--bs-pagination-focus-color:#3c4658;--bs-pagination-focus-bg:#fff;
  --bs-pagination-focus-box-shadow:0 0 0 2px #fff,0 0 0 4px #3b82f6;--bs-pagination-active-color:#fff;--bs-pagination-active-bg:#1a2233;--bs-pagination-active-border-color:#1a2233;
  --bs-pagination-disabled-color:#3c4658;--bs-pagination-disabled-bg:#fff;--bs-pagination-disabled-border-color:#d6dce7;gap:6px;flex-wrap:wrap}
.dt .page-link{min-width:32px;height:32px;padding:0 8px;font-weight:600;line-height:30px;text-align:center;border-radius:var(--bs-pagination-border-radius)!important;margin-left:0!important}
.dt .page-item.disabled .page-link{opacity:.4}
JavaScript
(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'); }
  function pageBtn(label, target, opts) {
    return '<li class="page-item' + (opts.current ? ' active' : '') + (opts.disabled ? ' disabled' : '') + '"><button class="page-link" type="button" data-page="' + target + '"' +
      (opts.aria ? ' aria-label="' + opts.aria + '"' : '') + (opts.current ? ' aria-current="page"' : '') + (opts.disabled ? ' disabled' : '') + '>' + label + '</button></li>';
  }
  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><th scope="row">' + r.num + '</th><td class="dt-who">' + r.client + '<small>' + r.email + '</small></td><td>' + money(r.amount) +
        '</td><td>' + r.date + '</td><td><span class="badge rounded-pill dt-' + r.status.toLowerCase() + '">' + r.status + '</span></td></tr>';
    }).join('') || '<tr><td colspan="5" class="dt-empty text-center">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();
})();

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<section data-dt aria-labelledby="dt-title" class="w-full max-w-[820px] rounded-2xl border border-[#e6eaf1] bg-white p-6 text-[#1a2233] shadow-[0_14px_34px_-18px_rgba(20,28,45,.16)]">
  <div class="mb-[18px] flex flex-wrap items-center justify-between gap-4">
    <h2 class="text-[1.05rem] font-bold tracking-[-.01em]"><span id="dt-title">Invoices</span><span data-dt-count class="ml-2 text-[.85rem] font-medium text-[#667189]"></span></h2>
    <input type="search" data-dt-search placeholder="Search client or number…" aria-label="Search invoices" class="w-[230px] max-w-full rounded-[10px] border border-[#8a94a6] px-3.5 py-[9px] text-[.88rem] transition-[border-color,box-shadow] duration-150 focus:border-[#3b82f6] focus:shadow-[0_0_0_3px_rgba(59,130,246,.25)] focus:outline-none motion-reduce:transition-none">
  </div>
  <div role="region" tabindex="0" aria-label="Invoices, scrollable" class="relative overflow-x-auto focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#3b82f6]">
    <table aria-labelledby="dt-title" class="w-full border-collapse text-left text-[.88rem]">
      <thead class="text-[.73rem] font-bold tracking-[.05em] whitespace-nowrap text-[#67718a] [&_th]:border-b-2 [&_th]:border-[#eef1f7] [&_th]:px-3.5 [&_th]:py-[11px]"><tr>
        <th scope="col" data-k="num" aria-sort="descending" class="group"><button type="button" class="inline-flex cursor-pointer items-center gap-[5px] rounded uppercase hover:text-[#1a2233] focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#3b82f6]">Invoice <svg viewBox="0 0 10 14" aria-hidden="true" class="h-[11px] w-2 fill-current opacity-45 group-aria-[sort=ascending]:text-[#2563eb] group-aria-[sort=ascending]:opacity-100 group-aria-[sort=descending]:text-[#2563eb] group-aria-[sort=descending]:opacity-100"><path class="group-aria-[sort=descending]:hidden" d="M5 1 1.5 5.5h7z"/><path class="group-aria-[sort=ascending]:hidden" d="M5 13 1.5 8.5h7z"/></svg></button></th>
        <th scope="col" data-k="client" class="group"><button type="button" class="inline-flex cursor-pointer items-center gap-[5px] rounded uppercase hover:text-[#1a2233] focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#3b82f6]">Client <svg viewBox="0 0 10 14" aria-hidden="true" class="h-[11px] w-2 fill-current opacity-45 group-aria-[sort=ascending]:text-[#2563eb] group-aria-[sort=ascending]:opacity-100 group-aria-[sort=descending]:text-[#2563eb] group-aria-[sort=descending]:opacity-100"><path class="group-aria-[sort=descending]:hidden" d="M5 1 1.5 5.5h7z"/><path class="group-aria-[sort=ascending]:hidden" d="M5 13 1.5 8.5h7z"/></svg></button></th>
        <th scope="col" data-k="amount" class="group"><button type="button" class="inline-flex cursor-pointer items-center gap-[5px] rounded uppercase hover:text-[#1a2233] focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#3b82f6]">Amount <svg viewBox="0 0 10 14" aria-hidden="true" class="h-[11px] w-2 fill-current opacity-45 group-aria-[sort=ascending]:text-[#2563eb] group-aria-[sort=ascending]:opacity-100 group-aria-[sort=descending]:text-[#2563eb] group-aria-[sort=descending]:opacity-100"><path class="group-aria-[sort=descending]:hidden" d="M5 1 1.5 5.5h7z"/><path class="group-aria-[sort=ascending]:hidden" d="M5 13 1.5 8.5h7z"/></svg></button></th>
        <th scope="col" data-k="date" class="group"><button type="button" class="inline-flex cursor-pointer items-center gap-[5px] rounded uppercase hover:text-[#1a2233] focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#3b82f6]">Issued <svg viewBox="0 0 10 14" aria-hidden="true" class="h-[11px] w-2 fill-current opacity-45 group-aria-[sort=ascending]:text-[#2563eb] group-aria-[sort=ascending]:opacity-100 group-aria-[sort=descending]:text-[#2563eb] group-aria-[sort=descending]:opacity-100"><path class="group-aria-[sort=descending]:hidden" d="M5 1 1.5 5.5h7z"/><path class="group-aria-[sort=ascending]:hidden" d="M5 13 1.5 8.5h7z"/></svg></button></th>
        <th scope="col" data-k="status" class="group"><button type="button" class="inline-flex cursor-pointer items-center gap-[5px] rounded uppercase hover:text-[#1a2233] focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#3b82f6]">Status <svg viewBox="0 0 10 14" aria-hidden="true" class="h-[11px] w-2 fill-current opacity-45 group-aria-[sort=ascending]:text-[#2563eb] group-aria-[sort=ascending]:opacity-100 group-aria-[sort=descending]:text-[#2563eb] group-aria-[sort=descending]:opacity-100"><path class="group-aria-[sort=descending]:hidden" d="M5 1 1.5 5.5h7z"/><path class="group-aria-[sort=ascending]:hidden" d="M5 13 1.5 8.5h7z"/></svg></button></th>
      </tr></thead>
      <tbody class="[&>tr>*]:border-b [&>tr>*]:border-[#f1f4f9] [&>tr>*]:px-3.5 [&>tr>*]:py-3"></tbody>
    </table>
  </div>
  <div class="mt-4 flex flex-wrap items-center justify-between gap-3 text-[.82rem] text-[#67718a]"><span data-dt-info aria-live="polite"></span><nav aria-label="Invoice pages"><div data-dt-pages class="flex flex-wrap gap-1.5"></div></nav></div>
</section>
JavaScript
(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.