Skip to content
UI ComponentData TableData DisplayNew

Smart Data Table with Sort, Filters, Bulk Actions & CSV Export

A feature-packed data table with no library: instant search, status chips with live counts, plan filter, three-state column sorting, select-all with an indeterminate state, a floating bulk-action bar with undo, double-click inline editing, a column chooser, compact density, CSV export of filtered or selected rows, pagination with page size, KPI cards and an MRR total. On phones every row turns into a readable card.

  • Published 23 Oct 2026
  • 1 views
Smart data table of customers with search, filter chips, sortable columns, row checkboxes and a CSV export button

Overview

Smart Data Table with Sort, Filters, Bulk Actions & CSV Export

Everything you expect from a data grid — search, sort, filter, bulk actions, inline edit and CSV export — in one small vanilla script.


What's Included

  • Search across name, email and company
  • Status chips with live counts + plan filter
  • Click headers to sort ascending, descending or off
  • Select rows (with indeterminate select-all) and run bulk actions with undo
  • Double-click (or Enter) to edit a cell inline
  • Column chooser, compact density and CSV export
  • Pagination with page size; rows become cards on phones

Dependencies

Already linked inside the component's files — no extra setup needed.


Desktop Preview


HTML

html
1<!doctype html>
2<html lang="en">
3  <head>
4    <meta charset="UTF-8" />
5    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6    <title>Smart Data Table | MetaSkillGroup</title>
7    <link rel="stylesheet" href="style.css" />
8  </head>
9  <body>
10    <!-- created by metaskillgroup-team -->
11    <main class="page">
12      <header class="page-head">
13        <div>
14          <h1>Customers</h1>
15          <p>Search, sort, filter, select and export — all in the browser.</p>
16        </div>
17        <div class="kpis" id="kpis"></div>
18      </header>
19
20      <section class="table-card">
21        <!-- Toolbar -->
22        <div class="toolbar">
23          <label class="search">
24            <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7" /><path d="M20 20l-3.5-3.5" /></svg>
25            <input type="search" id="search" placeholder="Search name, email or company" aria-label="Search customers" />
26          </label>
27
28          <div class="chips" id="status-chips" role="group" aria-label="Filter by status"></div>
29
30          <div class="tools">
31            <label class="select-wrap">
32              <span class="sr-only">Plan</span>
33              <select id="plan-filter">
34                <option value="">All plans</option>
35                <option>Starter</option>
36                <option>Pro</option>
37                <option>Business</option>
38              </select>
39            </label>
40
41            <div class="dropdown" id="columns-dd">
42              <button type="button" class="tool-btn" aria-expanded="false" aria-haspopup="true">
43                <svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2" /><path d="M9 4v16M15 4v16" /></svg>
44                Columns
45              </button>
46              <div class="dd-panel" id="columns-panel"></div>
47            </div>
48
49            <button type="button" class="tool-btn icon" id="density" aria-pressed="false" aria-label="Compact rows" title="Compact rows">
50              <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h16" /></svg>
51            </button>
52
53            <button type="button" class="tool-btn primary" id="export">
54              <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v12M7 10l5 5 5-5M5 21h14" /></svg>
55              Export CSV
56            </button>
57          </div>
58        </div>
59
60        <!-- Bulk action bar (shows when rows are selected) -->
61        <div class="bulk" id="bulk" aria-live="polite">
62          <span id="bulk-count">0 selected</span>
63          <button type="button" data-bulk="active">Mark active</button>
64          <button type="button" data-bulk="export">Export selected</button>
65          <button type="button" data-bulk="delete" class="danger">Delete</button>
66          <button type="button" class="bulk-clear" data-bulk="clear" aria-label="Clear selection">✕</button>
67        </div>
68
69        <div class="table-wrap">
70          <table id="table">
71            <thead id="thead"></thead>
72            <tbody id="tbody"></tbody>
73          </table>
74          <div class="empty" id="empty" hidden>
75            <span aria-hidden="true">🗂️</span>
76            <h3>No customers found</h3>
77            <p>Try a different search or clear the filters.</p>
78            <button type="button" id="clear-filters">Clear filters</button>
79          </div>
80        </div>
81
82        <!-- Footer: summary + pagination -->
83        <div class="foot">
84          <p id="summary"></p>
85          <div class="pager">
86            <label>Rows
87              <select id="page-size">
88                <option>10</option>
89                <option>20</option>
90                <option>50</option>
91              </select>
92            </label>
93            <nav class="pages" id="pages" aria-label="Pagination"></nav>
94          </div>
95        </div>
96      </section>
97    </main>
98
99    <div class="toast" id="toast" role="status" aria-live="polite"><span id="toast-text"></span><button type="button" id="toast-undo">Undo</button></div>
100
101    <script src="script.js"></script>
102  </body>
103</html>

CSS

css
1/* Importing Google Fonts - IBM Plex Sans + IBM Plex Mono */
2@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@500&display=swap");
3
4* {
5  margin: 0;
6  padding: 0;
7  box-sizing: border-box;
8  font-family: "IBM Plex Sans", sans-serif;
9}
10
11:root {
12  --bg: #f3f5f7;
13  --card: #ffffff;
14  --text: #17202a;
15  --muted: #637081;
16  --faint: #95a0ad;
17  --border: #e3e8ee;
18  --row-hover: #f7f9fb;
19  --accent: #0d9488;
20  --accent-soft: #e6f6f4;
21  --selected: #effaf8;
22  --ease: cubic-bezier(0.2, 0.8, 0.2, 1);
23}
24
25body {
26  min-height: 100vh;
27  background: var(--bg);
28  color: var(--text);
29}
30
31.sr-only {
32  position: absolute;
33  width: 1px;
34  height: 1px;
35  overflow: hidden;
36  clip: rect(0 0 0 0);
37}
38
39svg {
40  flex-shrink: 0;
41  fill: none;
42  stroke: currentColor;
43  stroke-width: 2;
44  stroke-linecap: round;
45  stroke-linejoin: round;
46}
47
48.page {
49  max-width: 1240px;
50  margin: 0 auto;
51  padding: 48px 20px 80px;
52}
53
54/* ---------- Header + KPIs ---------- */
55.page-head {
56  display: flex;
57  justify-content: space-between;
58  align-items: flex-end;
59  flex-wrap: wrap;
60  gap: 20px;
61  margin-bottom: 22px;
62}
63
64.page-head h1 {
65  font-size: 1.9rem;
66  letter-spacing: -0.02em;
67}
68
69.page-head p {
70  margin-top: 4px;
71  color: var(--muted);
72}
73
74.kpis {
75  display: flex;
76  gap: 12px;
77}
78
79.kpi {
80  min-width: 130px;
81  padding: 12px 16px;
82  border: 1px solid var(--border);
83  border-radius: 14px;
84  background: var(--card);
85}
86
87.kpi small {
88  color: var(--muted);
89  font-size: 0.78rem;
90}
91
92.kpi b {
93  display: block;
94  margin-top: 2px;
95  font-family: "IBM Plex Mono", monospace;
96  font-size: 1.15rem;
97}
98
99/* ---------- Card + toolbar ---------- */
100.table-card {
101  position: relative;
102  border: 1px solid var(--border);
103  border-radius: 18px;
104  background: var(--card);
105  box-shadow: 0 20px 50px -35px rgba(23, 32, 42, 0.35);
106}
107
108.toolbar {
109  display: flex;
110  align-items: center;
111  flex-wrap: wrap;
112  gap: 12px;
113  padding: 16px;
114  border-bottom: 1px solid var(--border);
115}
116
117.search {
118  display: flex;
119  align-items: center;
120  gap: 8px;
121  flex: 1 1 240px;
122  max-width: 320px;
123  height: 40px;
124  padding: 0 12px;
125  border: 1px solid var(--border);
126  border-radius: 10px;
127  color: var(--faint);
128}
129
130.search:focus-within {
131  border-color: var(--accent);
132  box-shadow: 0 0 0 3px rgba(13, 148, 136, 0.15);
133}
134
135.search svg {
136  width: 17px;
137  height: 17px;
138}
139
140.search input {
141  flex: 1;
142  min-width: 0;
143  border: 0;
144  color: var(--text);
145  font-size: 0.92rem;
146  outline: none;
147}
148
149.chips {
150  display: flex;
151  flex-wrap: wrap;
152  gap: 6px;
153}
154
155.chip {
156  display: inline-flex;
157  align-items: center;
158  gap: 6px;
159  padding: 7px 12px;
160  border: 1px solid var(--border);
161  border-radius: 999px;
162  background: var(--card);
163  color: var(--muted);
164  font-size: 0.85rem;
165  font-weight: 500;
166  cursor: pointer;
167  transition: all 0.2s;
168}
169
170.chip b {
171  color: var(--faint);
172  font-weight: 600;
173}
174
175.chip[aria-pressed="true"] {
176  border-color: var(--text);
177  background: var(--text);
178  color: #fff;
179}
180
181.chip[aria-pressed="true"] b {
182  color: #aab4bf;
183}
184
185.tools {
186  display: flex;
187  align-items: center;
188  gap: 8px;
189  margin-left: auto;
190}
191
192.select-wrap select,
193.pager select {
194  height: 40px;
195  padding: 0 32px 0 12px;
196  border: 1px solid var(--border);
197  border-radius: 10px;
198  appearance: none;
199  background: var(--card)
200    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23637081' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E")
201    no-repeat right 10px center / 15px;
202  color: var(--text);
203  font-size: 0.88rem;
204  cursor: pointer;
205}
206
207.tool-btn {
208  display: inline-flex;
209  align-items: center;
210  gap: 7px;
211  height: 40px;
212  padding: 0 13px;
213  border: 1px solid var(--border);
214  border-radius: 10px;
215  background: var(--card);
216  color: var(--text);
217  font-size: 0.88rem;
218  font-weight: 500;
219  cursor: pointer;
220  transition: background 0.2s, border-color 0.2s;
221}
222
223.tool-btn:hover {
224  background: var(--row-hover);
225}
226
227.tool-btn svg {
228  width: 17px;
229  height: 17px;
230}
231
232.tool-btn.icon {
233  width: 40px;
234  justify-content: center;
235  padding: 0;
236}
237
238.tool-btn[aria-pressed="true"] {
239  border-color: var(--accent);
240  background: var(--accent-soft);
241  color: var(--accent);
242}
243
244.tool-btn.primary {
245  border-color: var(--accent);
246  background: var(--accent);
247  color: #fff;
248}
249
250.tool-btn.primary:hover {
251  background: #0b7f75;
252}
253
254.chip:focus-visible,
255.tool-btn:focus-visible,
256th button:focus-visible,
257.pages button:focus-visible,
258.row-menu-btn:focus-visible,
259input[type="checkbox"]:focus-visible {
260  outline: 2px solid var(--accent);
261  outline-offset: 2px;
262}
263
264/* Column chooser dropdown */
265.dropdown {
266  position: relative;
267}
268
269.dd-panel {
270  position: absolute;
271  top: calc(100% + 6px);
272  right: 0;
273  z-index: 20;
274  width: 210px;
275  padding: 6px;
276  border: 1px solid var(--border);
277  border-radius: 12px;
278  background: var(--card);
279  box-shadow: 0 20px 40px -18px rgba(23, 32, 42, 0.35);
280  opacity: 0;
281  visibility: hidden;
282  transform: translateY(-4px);
283  transition: opacity 0.18s, transform 0.2s var(--ease), visibility 0.18s;
284}
285
286.dropdown.is-open .dd-panel {
287  opacity: 1;
288  visibility: visible;
289  transform: none;
290}
291
292.dd-panel label {
293  display: flex;
294  align-items: center;
295  gap: 9px;
296  padding: 8px 10px;
297  border-radius: 8px;
298  font-size: 0.88rem;
299  cursor: pointer;
300}
301
302.dd-panel label:hover {
303  background: var(--row-hover);
304}
305
306input[type="checkbox"] {
307  width: 16px;
308  height: 16px;
309  accent-color: var(--accent);
310  cursor: pointer;
311}
312
313/* ---------- Bulk bar ---------- */
314.bulk {
315  position: absolute;
316  left: 50%;
317  bottom: 74px;
318  z-index: 15;
319  display: flex;
320  align-items: center;
321  gap: 6px;
322  padding: 8px 8px 8px 16px;
323  border-radius: 14px;
324  background: var(--text);
325  color: #fff;
326  font-size: 0.88rem;
327  box-shadow: 0 20px 40px -14px rgba(23, 32, 42, 0.6);
328  opacity: 0;
329  visibility: hidden;
330  transform: translate(-50%, 12px);
331  transition: opacity 0.25s, transform 0.3s var(--ease), visibility 0.25s;
332}
333
334.bulk.is-on {
335  opacity: 1;
336  visibility: visible;
337  transform: translate(-50%, 0);
338}
339
340.bulk span {
341  margin-right: 8px;
342  font-weight: 600;
343  white-space: nowrap;
344}
345
346.bulk button {
347  padding: 7px 12px;
348  border: 0;
349  border-radius: 9px;
350  background: rgba(255, 255, 255, 0.1);
351  color: #fff;
352  font-size: 0.85rem;
353  font-weight: 500;
354  white-space: nowrap;
355  cursor: pointer;
356}
357
358.bulk button:hover {
359  background: rgba(255, 255, 255, 0.2);
360}
361
362.bulk .danger {
363  background: #e5484d;
364}
365
366.bulk .bulk-clear {
367  background: none;
368  color: #aab4bf;
369}
370
371/* ---------- Table ---------- */
372.table-wrap {
373  position: relative;
374  max-height: 560px;
375  overflow: auto;
376}
377
378table {
379  width: 100%;
380  border-collapse: separate;
381  border-spacing: 0;
382  font-size: 0.9rem;
383}
384
385thead th {
386  position: sticky;
387  top: 0;
388  z-index: 2;
389  padding: 0 14px;
390  height: 44px;
391  border-bottom: 1px solid var(--border);
392  background: #f8fafb;
393  color: var(--muted);
394  font-size: 0.78rem;
395  font-weight: 600;
396  letter-spacing: 0.04em;
397  text-align: left;
398  text-transform: uppercase;
399  white-space: nowrap;
400}
401
402th button {
403  display: inline-flex;
404  align-items: center;
405  gap: 5px;
406  border: 0;
407  background: none;
408  color: inherit;
409  font: inherit;
410  letter-spacing: inherit;
411  text-transform: inherit;
412  cursor: pointer;
413}
414
415th button:hover {
416  color: var(--text);
417}
418
419.sort-icon {
420  width: 14px;
421  height: 14px;
422  opacity: 0.35;
423  transition: transform 0.2s, opacity 0.2s;
424}
425
426th[aria-sort="ascending"] .sort-icon,
427th[aria-sort="descending"] .sort-icon {
428  opacity: 1;
429  color: var(--accent);
430}
431
432th[aria-sort="descending"] .sort-icon {
433  transform: rotate(180deg);
434}
435
436th.num,
437td.num {
438  text-align: right;
439}
440
441th.num button {
442  flex-direction: row-reverse;
443}
444
445th.check,
446td.check {
447  width: 44px;
448  padding-right: 0;
449}
450
451tbody td {
452  height: 58px;
453  padding: 0 14px;
454  border-bottom: 1px solid var(--border);
455  white-space: nowrap;
456  transition: background 0.15s;
457}
458
459.compact tbody td {
460  height: 42px;
461}
462
463tbody tr:hover td {
464  background: var(--row-hover);
465}
466
467tbody tr.is-selected td {
468  background: var(--selected);
469}
470
471tbody tr.is-new td {
472  animation: flash 1.2s ease;
473}
474
475@keyframes flash {
476  from {
477    background: #fef3c7;
478  }
479}
480
481.person {
482  display: flex;
483  align-items: center;
484  gap: 11px;
485}
486
487.av {
488  display: grid;
489  place-items: center;
490  flex-shrink: 0;
491  width: 34px;
492  height: 34px;
493  border-radius: 50%;
494  background: hsl(var(--h) 70% 92%);
495  color: hsl(var(--h) 55% 32%);
496  font-size: 0.78rem;
497  font-weight: 700;
498}
499
500.compact .av {
501  width: 26px;
502  height: 26px;
503  font-size: 0.68rem;
504}
505
506.person b {
507  display: block;
508  font-weight: 600;
509}
510
511.person small {
512  color: var(--muted);
513}
514
515.compact .person small {
516  display: none;
517}
518
519td.company {
520  cursor: text;
521}
522
523td.company .edit-hint {
524  margin-left: 6px;
525  color: var(--faint);
526  font-size: 0.75rem;
527  opacity: 0;
528  transition: opacity 0.2s;
529}
530
531tr:hover td.company .edit-hint {
532  opacity: 1;
533}
534
535td.company input {
536  width: 100%;
537  padding: 6px 8px;
538  border: 1.5px solid var(--accent);
539  border-radius: 8px;
540  font: inherit;
541  outline: none;
542}
543
544.mono {
545  font-family: "IBM Plex Mono", monospace;
546  font-size: 0.86rem;
547}
548
549.status {
550  display: inline-flex;
551  align-items: center;
552  gap: 6px;
553  padding: 3px 10px;
554  border-radius: 999px;
555  font-size: 0.8rem;
556  font-weight: 600;
557}
558
559.status::before {
560  content: "";
561  width: 6px;
562  height: 6px;
563  border-radius: 50%;
564  background: currentColor;
565}
566
567.status.Active { background: #e7f7ee; color: #15803d; }
568.status.Trial { background: #e8f0ff; color: #2459d6; }
569.status.Past-due { background: #fff4e0; color: #b45309; }
570.status.Churned { background: #f1f2f4; color: #6b7280; }
571
572.plan {
573  padding: 3px 9px;
574  border: 1px solid var(--border);
575  border-radius: 7px;
576  color: var(--muted);
577  font-size: 0.8rem;
578  font-weight: 500;
579}
580
581.row-menu {
582  position: relative;
583  text-align: right;
584}
585
586.row-menu-btn {
587  display: inline-grid;
588  place-items: center;
589  width: 32px;
590  height: 32px;
591  border: 0;
592  border-radius: 8px;
593  background: none;
594  color: var(--muted);
595  cursor: pointer;
596}
597
598.row-menu-btn:hover {
599  background: var(--border);
600}
601
602.row-menu-btn svg {
603  width: 18px;
604  height: 18px;
605}
606
607.menu {
608  position: absolute;
609  right: 14px;
610  top: 44px;
611  z-index: 5;
612  width: 150px;
613  padding: 5px;
614  border: 1px solid var(--border);
615  border-radius: 11px;
616  background: var(--card);
617  box-shadow: 0 16px 34px -14px rgba(23, 32, 42, 0.35);
618  text-align: left;
619}
620
621.menu button {
622  display: block;
623  width: 100%;
624  padding: 8px 10px;
625  border: 0;
626  border-radius: 7px;
627  background: none;
628  font-size: 0.86rem;
629  text-align: left;
630  cursor: pointer;
631}
632
633.menu button:hover {
634  background: var(--row-hover);
635}
636
637.menu .danger {
638  color: #d92d20;
639}
640
641/* ---------- Empty state ---------- */
642.empty {
643  padding: 60px 20px;
644  text-align: center;
645}
646
647.empty span {
648  font-size: 2.2rem;
649}
650
651.empty h3 {
652  margin: 10px 0 4px;
653}
654
655.empty p {
656  color: var(--muted);
657}
658
659.empty button {
660  margin-top: 16px;
661  padding: 9px 16px;
662  border: 1px solid var(--border);
663  border-radius: 10px;
664  background: var(--card);
665  font-size: 0.88rem;
666  font-weight: 600;
667  cursor: pointer;
668}
669
670/* ---------- Footer + pagination ---------- */
671.foot {
672  display: flex;
673  justify-content: space-between;
674  align-items: center;
675  flex-wrap: wrap;
676  gap: 12px;
677  padding: 14px 16px;
678}
679
680.foot p {
681  color: var(--muted);
682  font-size: 0.88rem;
683}
684
685.foot p b {
686  color: var(--text);
687}
688
689.pager {
690  display: flex;
691  align-items: center;
692  gap: 14px;
693}
694
695.pager label {
696  display: flex;
697  align-items: center;
698  gap: 8px;
699  color: var(--muted);
700  font-size: 0.86rem;
701}
702
703.pager select {
704  height: 34px;
705}
706
707.pages {
708  display: flex;
709  gap: 4px;
710}
711
712.pages button {
713  min-width: 34px;
714  height: 34px;
715  padding: 0 8px;
716  border: 1px solid transparent;
717  border-radius: 9px;
718  background: none;
719  color: var(--text);
720  font-size: 0.88rem;
721  cursor: pointer;
722}
723
724.pages button:hover:not(:disabled) {
725  background: var(--row-hover);
726}
727
728.pages button[aria-current="page"] {
729  border-color: var(--accent);
730  background: var(--accent-soft);
731  color: var(--accent);
732  font-weight: 700;
733}
734
735.pages button:disabled {
736  color: var(--faint);
737  cursor: default;
738}
739
740.pages .gap {
741  display: grid;
742  place-items: center;
743  width: 20px;
744  color: var(--faint);
745}
746
747[hidden] {
748  display: none !important;
749}
750
751/* ---------- Toast with undo ---------- */
752.toast {
753  position: fixed;
754  left: 50%;
755  bottom: 24px;
756  z-index: 50;
757  display: flex;
758  align-items: center;
759  gap: 14px;
760  padding: 10px 10px 10px 16px;
761  border-radius: 12px;
762  background: var(--text);
763  color: #fff;
764  font-size: 0.9rem;
765  opacity: 0;
766  visibility: hidden;
767  transform: translate(-50%, 16px);
768  transition: opacity 0.25s, transform 0.3s var(--ease), visibility 0.25s;
769}
770
771.toast.show {
772  opacity: 1;
773  visibility: visible;
774  transform: translate(-50%, 0);
775}
776
777.toast button {
778  padding: 6px 12px;
779  border: 0;
780  border-radius: 8px;
781  background: rgba(255, 255, 255, 0.15);
782  color: #5eead4;
783  font-weight: 600;
784  cursor: pointer;
785}
786
787/* ---------- Phones: each row becomes a card ---------- */
788@media (max-width: 760px) {
789  .kpis {
790    width: 100%;
791  }
792
793  .kpi {
794    flex: 1;
795    min-width: 0;
796    padding: 10px 12px;
797  }
798
799  .kpi b {
800    font-size: 0.95rem;
801    overflow: hidden;
802    text-overflow: ellipsis;
803  }
804
805  .tools {
806    margin-left: 0;
807    flex-wrap: wrap;
808  }
809
810  .search {
811    max-width: none;
812  }
813
814  .table-wrap {
815    max-height: none;
816  }
817
818  thead {
819    display: none;
820  }
821
822  table,
823  tbody,
824  tr,
825  td {
826    display: block;
827  }
828
829  tbody tr {
830    position: relative;
831    margin: 12px;
832    padding: 6px 4px;
833    border: 1px solid var(--border);
834    border-radius: 14px;
835  }
836
837  tbody td,
838  .compact tbody td {
839    display: flex;
840    justify-content: space-between;
841    align-items: center;
842    height: auto;
843    min-height: 38px;
844    padding: 6px 12px;
845    border: 0;
846  }
847
848  tbody td::before {
849    content: attr(data-label);
850    font-family: "IBM Plex Sans", sans-serif;
851    color: var(--muted);
852    font-size: 0.78rem;
853    font-weight: 600;
854    text-transform: uppercase;
855  }
856
857  tbody td.check,
858  tbody td.row-menu {
859    position: absolute;
860    top: 8px;
861    min-height: 0;
862    padding: 6px;
863  }
864
865  tbody td.check {
866    right: 46px;
867  }
868
869  tbody td.row-menu {
870    right: 4px;
871  }
872
873  tbody td.check::before,
874  tbody td.row-menu::before,
875  tbody td.person-cell::before {
876    content: none;
877  }
878
879  tbody td.person-cell {
880    padding-right: 90px;
881  }
882
883  td.num {
884    text-align: right;
885  }
886
887  .bulk {
888    position: fixed;
889    bottom: 16px;
890    width: calc(100% - 24px);
891    flex-wrap: wrap;
892  }
893
894  .foot {
895    flex-direction: column;
896    align-items: stretch;
897  }
898
899  .pager {
900    justify-content: space-between;
901  }
902}
903
904@media (prefers-reduced-motion: reduce) {
905  *,
906  *::before,
907  *::after {
908    animation: none !important;
909    transition: none !important;
910  }
911}

JavaScript

javascript
1// ---------- Sample data (replace `rows` with your API data) ----------
2const FIRST = ["Aarav", "Diya", "Kabir", "Meera", "Rohan", "Ananya", "Vihaan", "Isha", "Arjun", "Saanvi", "Liam", "Emma", "Noah", "Olivia", "Yuki", "Mateo", "Zara", "Ethan", "Priya", "Lucas", "Nora"];
3const LAST = ["Sharma", "Iyer", "Khan", "Patel", "Reddy", "Gupta", "Nair", "Singh", "Das", "Brown", "Garcia", "Kim", "Silva", "Müller", "Rossi", "Tanaka"];
4const COMPANIES = ["Northwind", "Pixelcraft", "Bluefin Labs", "Kiteworks", "Saffron AI", "Orbitly", "Monsoon Studio", "Quill & Co", "Vertex Health", "Lumen", "Tiffin Box", "Cobalt Pay"];
5const PLANS = ["Starter", "Pro", "Business"];
6const PRICE = { Starter: 999, Pro: 2499, Business: 7999 };
7const STATUSES = ["Active", "Trial", "Past due", "Churned"];
8
9// Tiny seeded random so the demo data is the same on every load
10let seed = 7;
11const rand = () => ((seed = (seed * 16807) % 2147483647) / 2147483647);
12const pick = (arr) => arr[Math.floor(rand() * arr.length)];
13
14let rows = Array.from({ length: 42 }, (_, i) => {
15  const first = pick(FIRST);
16  const last = pick(LAST);
17  const plan = pick(PLANS);
18  const status = rand() < 0.55 ? "Active" : pick(STATUSES);
19  const seats = 1 + Math.floor(rand() * 12);
20  return {
21    id: i + 1,
22    name: `${first} ${last}`,
23    email: `${first}.${last}`.toLowerCase().replace(/[^a-z.]/g, "") + "@" + pick(["gmail.com", "outlook.com", "company.io"]),
24    company: pick(COMPANIES),
25    plan,
26    status,
27    mrr: status === "Churned" ? 0 : PRICE[plan] * (plan === "Starter" ? 1 : seats),
28    joined: new Date(2026, 9, 14 - Math.floor(rand() * 600)),
29  };
30});
31
32const COLUMNS = [
33  { key: "name", label: "Customer", locked: true },
34  { key: "company", label: "Company" },
35  { key: "plan", label: "Plan" },
36  { key: "status", label: "Status" },
37  { key: "mrr", label: "MRR", num: true },
38  { key: "joined", label: "Joined" },
39];
40
41const inr = new Intl.NumberFormat("en-IN", { style: "currency", currency: "INR", maximumFractionDigits: 0 });
42const dateFmt = new Intl.DateTimeFormat("en-IN", { day: "numeric", month: "short", year: "numeric" });
43
44const state = {
45  query: "",
46  status: "All",
47  plan: "",
48  sort: { key: "joined", dir: "desc" },
49  page: 1,
50  pageSize: 10,
51  selected: new Set(),
52  hidden: new Set(),
53  compact: false,
54};
55
56const $ = (id) => document.getElementById(id);
57const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
58const hue = (s) => [...s].reduce((h, c) => h + c.charCodeAt(0), 0) % 360;
59
60// ---------- Filtering + sorting ----------
61const filtered = () => {
62  const q = state.query.toLowerCase();
63  const list = rows.filter(
64    (r) =>
65      (state.status === "All" || r.status === state.status) &&
66      (!state.plan || r.plan === state.plan) &&
67      (!q || r.name.toLowerCase().includes(q) || r.email.includes(q) || r.company.toLowerCase().includes(q)),
68  );
69  const { key, dir } = state.sort;
70  if (key) {
71    list.sort((a, b) => {
72      const x = a[key];
73      const y = b[key];
74      const cmp = x instanceof Date ? x - y : typeof x === "number" ? x - y : String(x).localeCompare(String(y));
75      return dir === "asc" ? cmp : -cmp;
76    });
77  }
78  return list;
79};
80
81// ---------- Render ----------
82const visibleCols = () => COLUMNS.filter((c) => !state.hidden.has(c.key));
83
84const renderHead = (pageRows) => {
85  const allOnPage = pageRows.length > 0 && pageRows.every((r) => state.selected.has(r.id));
86  const someOnPage = pageRows.some((r) => state.selected.has(r.id));
87  $("thead").innerHTML = `<tr>
88    <th class="check"><input type="checkbox" id="select-all" aria-label="Select all on this page" ${allOnPage ? "checked" : ""} /></th>
89    ${visibleCols()
90      .map((c) => {
91        const sort = state.sort.key === c.key ? (state.sort.dir === "asc" ? "ascending" : "descending") : "none";
92        return `<th class="${c.num ? "num" : ""}" aria-sort="${sort}"><button type="button" data-sort="${c.key}">${c.label}
93          <svg class="sort-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M6 11l6-6 6 6"/></svg></button></th>`;
94      })
95      .join("")}
96    <th class="row-menu"><span class="sr-only">Actions</span></th>
97  </tr>`;
98  $("select-all").indeterminate = someOnPage && !allOnPage;
99};
100
101const cell = (c, r) => {
102  switch (c.key) {
103    case "name":
104      return `<td class="person-cell" data-label="Customer"><div class="person"><span class="av" style="--h:${hue(r.name)}">${r.name.split(" ").map((p) => p[0]).join("")}</span><div><b>${esc(r.name)}</b><small>${esc(r.email)}</small></div></div></td>`;
105    case "company":
106      return `<td class="company" data-label="Company" data-id="${r.id}" tabindex="0" title="Double-click to edit">${esc(r.company)}<span class="edit-hint">✎</span></td>`;
107    case "plan":
108      return `<td data-label="Plan"><span class="plan">${r.plan}</span></td>`;
109    case "status":
110      return `<td data-label="Status"><span class="status ${r.status.replace(" ", "-")}">${r.status}</span></td>`;
111    case "mrr":
112      return `<td class="num mono" data-label="MRR">${inr.format(r.mrr)}</td>`;
113    case "joined":
114      return `<td data-label="Joined">${dateFmt.format(r.joined)}</td>`;
115  }
116};
117
118let lastAdded = null;
119const render = () => {
120  const list = filtered();
121  const pages = Math.max(1, Math.ceil(list.length / state.pageSize));
122  state.page = Math.min(state.page, pages);
123  const start = (state.page - 1) * state.pageSize;
124  const pageRows = list.slice(start, start + state.pageSize);
125
126  renderHead(pageRows);
127  $("tbody").innerHTML = pageRows
128    .map(
129      (r) => `<tr data-id="${r.id}" class="${state.selected.has(r.id) ? "is-selected" : ""} ${lastAdded?.has(r.id) ? "is-new" : ""}">
130        <td class="check"><input type="checkbox" data-select="${r.id}" aria-label="Select ${esc(r.name)}" ${state.selected.has(r.id) ? "checked" : ""} /></td>
131        ${visibleCols().map((c) => cell(c, r)).join("")}
132        <td class="row-menu"><button type="button" class="row-menu-btn" data-menu="${r.id}" aria-label="Actions for ${esc(r.name)}" aria-haspopup="true">
133          <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/></svg></button></td>
134      </tr>`,
135    )
136    .join("");
137  lastAdded = null;
138
139  $("table").hidden = list.length === 0;
140  $("empty").hidden = list.length > 0;
141  $("table").classList.toggle("compact", state.compact);
142
143  const mrr = list.reduce((s, r) => s + r.mrr, 0);
144  $("summary").innerHTML = list.length
145    ? `Showing <b>${start + 1}–${start + pageRows.length}</b> of <b>${list.length}</b> · MRR <b>${inr.format(mrr)}</b>`
146    : "No results";
147
148  renderPages(pages);
149  renderChips();
150  renderKpis();
151  renderBulk();
152};
153
154const renderPages = (pages) => {
155  const p = state.page;
156  const nums = [...new Set([1, p - 1, p, p + 1, pages])].filter((n) => n >= 1 && n <= pages).sort((a, b) => a - b);
157  let html = `<button type="button" data-page="${p - 1}" ${p === 1 ? "disabled" : ""} aria-label="Previous page">‹</button>`;
158  nums.forEach((n, i) => {
159    if (i && n - nums[i - 1] > 1) html += `<span class="gap">…</span>`;
160    html += `<button type="button" data-page="${n}" ${n === p ? 'aria-current="page"' : ""}>${n}</button>`;
161  });
162  html += `<button type="button" data-page="${p + 1}" ${p === pages ? "disabled" : ""} aria-label="Next page">›</button>`;
163  $("pages").innerHTML = html;
164};
165
166const renderChips = () => {
167  const base = rows.filter((r) => !state.plan || r.plan === state.plan);
168  $("status-chips").innerHTML = ["All", ...STATUSES]
169    .map((s) => {
170      const n = s === "All" ? base.length : base.filter((r) => r.status === s).length;
171      return `<button type="button" class="chip" data-status="${s}" aria-pressed="${state.status === s}">${s} <b>${n}</b></button>`;
172    })
173    .join("");
174};
175
176const renderKpis = () => {
177  const active = rows.filter((r) => r.status === "Active");
178  $("kpis").innerHTML = `
179    <div class="kpi"><small>Customers</small><b>${rows.length}</b></div>
180    <div class="kpi"><small>Active MRR</small><b>${inr.format(active.reduce((s, r) => s + r.mrr, 0))}</b></div>
181    <div class="kpi"><small>On trial</small><b>${rows.filter((r) => r.status === "Trial").length}</b></div>`;
182};
183
184const renderBulk = () => {
185  const n = state.selected.size;
186  $("bulk").classList.toggle("is-on", n > 0);
187  $("bulk-count").textContent = `${n} selected`;
188};
189
190const renderColumnsPanel = () => {
191  $("columns-panel").innerHTML = COLUMNS.filter((c) => !c.locked)
192    .map((c) => `<label><input type="checkbox" data-col="${c.key}" ${state.hidden.has(c.key) ? "" : "checked"} /> ${c.label}</label>`)
193    .join("");
194};
195
196// ---------- Toast with undo ----------
197let undoFn = null;
198let toastTimer;
199const toast = (text, undo) => {
200  $("toast-text").textContent = text;
201  $("toast-undo").hidden = !undo;
202  undoFn = undo;
203  $("toast").classList.add("show");
204  clearTimeout(toastTimer);
205  toastTimer = setTimeout(() => $("toast").classList.remove("show"), 5000);
206};
207$("toast-undo").addEventListener("click", () => {
208  undoFn?.();
209  $("toast").classList.remove("show");
210});
211
212const removeRows = (ids) => {
213  const removed = rows.map((r, i) => ({ r, i })).filter(({ r }) => ids.includes(r.id));
214  rows = rows.filter((r) => !ids.includes(r.id));
215  ids.forEach((id) => state.selected.delete(id));
216  render();
217  toast(`${removed.length} customer${removed.length > 1 ? "s" : ""} deleted`, () => {
218    removed.forEach(({ r, i }) => rows.splice(i, 0, r));
219    lastAdded = new Set(ids);
220    render();
221  });
222};
223
224// ---------- CSV export ----------
225const exportCSV = (list, name) => {
226  const cols = visibleCols();
227  const quote = (v) => `"${String(v).replace(/"/g, '""')}"`;
228  const lines = [
229    ["Name", "Email", ...cols.filter((c) => c.key !== "name").map((c) => c.label)].map(quote).join(","),
230    ...list.map((r) =>
231      [r.name, r.email, ...cols.filter((c) => c.key !== "name").map((c) => (c.key === "joined" ? r.joined.toISOString().slice(0, 10) : r[c.key]))]
232        .map(quote)
233        .join(","),
234    ),
235  ];
236  const blob = new Blob([lines.join("\n")], { type: "text/csv;charset=utf-8" });
237  const a = Object.assign(document.createElement("a"), { href: URL.createObjectURL(blob), download: name });
238  a.click();
239  setTimeout(() => URL.revokeObjectURL(a.href), 1000);
240  toast(`Exported ${list.length} rows to ${name}`);
241};
242
243// ---------- Events ----------
244let searchTimer;
245$("search").addEventListener("input", (e) => {
246  clearTimeout(searchTimer);
247  searchTimer = setTimeout(() => {
248    state.query = e.target.value.trim();
249    state.page = 1;
250    render();
251  }, 150);
252});
253
254$("status-chips").addEventListener("click", (e) => {
255  const chip = e.target.closest(".chip");
256  if (!chip) return;
257  state.status = chip.dataset.status;
258  state.page = 1;
259  render();
260});
261
262$("plan-filter").addEventListener("change", (e) => {
263  state.plan = e.target.value;
264  state.page = 1;
265  render();
266});
267
268$("thead").addEventListener("click", (e) => {
269  const btn = e.target.closest("[data-sort]");
270  if (!btn) return;
271  const key = btn.dataset.sort;
272  // Click cycles: ascending → descending → off
273  if (state.sort.key !== key) state.sort = { key, dir: "asc" };
274  else if (state.sort.dir === "asc") state.sort.dir = "desc";
275  else state.sort = { key: null, dir: "asc" };
276  render();
277});
278
279$("thead").addEventListener("change", (e) => {
280  if (e.target.id !== "select-all") return;
281  const list = filtered();
282  const start = (state.page - 1) * state.pageSize;
283  list.slice(start, start + state.pageSize).forEach((r) => (e.target.checked ? state.selected.add(r.id) : state.selected.delete(r.id)));
284  render();
285});
286
287$("tbody").addEventListener("change", (e) => {
288  const id = Number(e.target.dataset.select);
289  if (!id) return;
290  e.target.checked ? state.selected.add(id) : state.selected.delete(id);
291  render();
292});
293
294$("pages").addEventListener("click", (e) => {
295  const b = e.target.closest("[data-page]");
296  if (!b || b.disabled) return;
297  state.page = Number(b.dataset.page);
298  render();
299});
300
301$("page-size").addEventListener("change", (e) => {
302  state.pageSize = Number(e.target.value);
303  state.page = 1;
304  render();
305});
306
307$("density").addEventListener("click", (e) => {
308  state.compact = !state.compact;
309  e.currentTarget.setAttribute("aria-pressed", state.compact);
310  render();
311});
312
313$("export").addEventListener("click", () => exportCSV(filtered(), "customers.csv"));
314
315$("clear-filters").addEventListener("click", () => {
316  Object.assign(state, { query: "", status: "All", plan: "", page: 1 });
317  $("search").value = "";
318  $("plan-filter").value = "";
319  render();
320});
321
322// Column chooser
323const colDD = $("columns-dd");
324colDD.querySelector("button").addEventListener("click", (e) => {
325  e.stopPropagation();
326  const open = !colDD.classList.contains("is-open");
327  colDD.classList.toggle("is-open", open);
328  e.currentTarget.setAttribute("aria-expanded", open);
329});
330$("columns-panel").addEventListener("click", (e) => e.stopPropagation());
331$("columns-panel").addEventListener("change", (e) => {
332  const key = e.target.dataset.col;
333  e.target.checked ? state.hidden.delete(key) : state.hidden.add(key);
334  render();
335});
336
337// Bulk actions
338$("bulk").addEventListener("click", (e) => {
339  const action = e.target.closest("[data-bulk]")?.dataset.bulk;
340  const ids = [...state.selected];
341  if (action === "active") {
342    rows.forEach((r) => ids.includes(r.id) && (r.status = "Active"));
343    toast(`${ids.length} marked active`);
344    lastAdded = new Set(ids);
345  }
346  if (action === "export") exportCSV(rows.filter((r) => ids.includes(r.id)), "selected-customers.csv");
347  if (action === "delete") return removeRows(ids);
348  if (action === "clear" || action === "active") state.selected.clear();
349  render();
350});
351
352// Row menu (⋯)
353const closeMenus = () => document.querySelectorAll(".menu").forEach((m) => m.remove());
354$("tbody").addEventListener("click", (e) => {
355  const btn = e.target.closest("[data-menu]");
356  const item = e.target.closest(".menu button");
357  if (item) {
358    const id = Number(item.closest(".menu").dataset.for);
359    const row = rows.find((r) => r.id === id);
360    closeMenus();
361    if (item.dataset.act === "view") toast(`Opening ${row.name}…`);
362    if (item.dataset.act === "edit") startEdit(document.querySelector(`td.company[data-id="${id}"]`));
363    if (item.dataset.act === "delete") removeRows([id]);
364    return;
365  }
366  if (!btn) return;
367  e.stopPropagation();
368  const already = btn.parentElement.querySelector(".menu");
369  closeMenus();
370  if (already) return;
371  btn.insertAdjacentHTML(
372    "afterend",
373    `<div class="menu" data-for="${btn.dataset.menu}" role="menu">
374      <button type="button" role="menuitem" data-act="view">View profile</button>
375      <button type="button" role="menuitem" data-act="edit">Edit company</button>
376      <button type="button" role="menuitem" data-act="delete" class="danger">Delete</button>
377    </div>`,
378  );
379  btn.nextElementSibling.querySelector("button").focus();
380});
381
382document.addEventListener("click", (e) => {
383  if (!e.target.closest(".menu")) closeMenus();
384  if (!e.target.closest("#columns-dd")) colDD.classList.remove("is-open");
385});
386document.addEventListener("keydown", (e) => {
387  if (e.key === "Escape") {
388    closeMenus();
389    colDD.classList.remove("is-open");
390  }
391});
392
393// Inline edit: double-click (or Enter) on a company cell
394const startEdit = (td) => {
395  if (!td || td.querySelector("input")) return;
396  const row = rows.find((r) => r.id === Number(td.dataset.id));
397  td.innerHTML = `<input type="text" value="${esc(row.company)}" aria-label="Company" />`;
398  const input = td.querySelector("input");
399  input.focus();
400  input.select();
401  let done = false;
402  const finish = (save) => {
403    if (done) return;
404    done = true;
405    if (save && input.value.trim() && input.value.trim() !== row.company) {
406      row.company = input.value.trim();
407      lastAdded = new Set([row.id]);
408      toast(`Company updated to “${row.company}”`);
409    }
410    render();
411  };
412  input.addEventListener("keydown", (e) => {
413    if (e.key === "Enter") finish(true);
414    if (e.key === "Escape") finish(false);
415  });
416  input.addEventListener("blur", () => finish(true));
417};
418$("tbody").addEventListener("dblclick", (e) => startEdit(e.target.closest("td.company")));
419$("tbody").addEventListener("keydown", (e) => {
420  if (e.key === "Enter" && e.target.matches("td.company")) startEdit(e.target);
421});
422
423renderColumnsPanel();
424render();

Built by MetaSkillGroup — copy it, use it, make it yours.

Features

  • Search across name, email and company

  • Status chips with live counts + plan filter

  • Click headers to sort ascending, descending or off

  • Select rows (with indeterminate select-all) and run bulk actions with undo

  • Double-click (or Enter) to edit a cell inline

  • Column chooser, compact density and CSV export

  • Pagination with page size; rows become cards on phones

Built With

🌐

HTML5

🎨

CSS3

⚡

JavaScript

Compatibility

Browsers
ChromeEdgeFirefoxSafariOperaBraveArcVivaldi
Devices
DesktopTabletMobile
Frameworks
Vanilla JSHTML/CSS

❓ FAQs

Replace the generated rows array with your data (same fields: id, name, email, company, plan, status, mrr, joined as a Date). Everything else updates automatically.

Add an entry to COLUMNS and a matching case in the cell() function in script.js. It then appears in sorting, the column chooser and CSV export.

In the demo it removes rows in the browser with a 5-second Undo. Call your API inside removeRows() to make it permanent.

MetaSkillGroup

Written by

MetaSkillGroup

October 23, 2026

Guides from our blog

Need it built for you?

Built to the same standard as our client work.

If nothing here fits exactly, tell us what you need — our core developers build it. Requirements call first, a written SRS before any code, and a price agreed up front.