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
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
- Google Fonts — IBM Plex Sans + IBM Plex Mono https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@500&display=swap
Already linked inside the component's files — no extra setup needed.
Desktop Preview
HTML
html1<!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
css1/* 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
javascript1// ---------- 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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
❓ 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.

Written by
MetaSkillGroup
Guides from our blog
Frontend Developer Roadmap 2026: A Step-by-Step Guide for BeginnersA simple, step-by-step frontend developer roadmap for 2026. Learn HTML, CSS, JavaScript, Git, React, TypeScript and Next.js in the right order, with a 6-month study plan, beginner projects, free resources and a checklist for your first frontend internship.
MERN Stack Roadmap for Beginners 2026: A Step-by-Step Guide to Becoming a Full-Stack DeveloperConfused about where to start with MERN? This step-by-step MERN stack roadmap for 2026 takes you from zero to your first full-stack project. Learn JavaScript, Node.js, Express, MongoDB and React in the right order, with a clear timeline, milestone projects, common mistakes and tips to learn faster with AI tools.
Top 7 React Optimization Techniques to Boost Your App Performance in 2026.Learn the top 7 React optimization techniques including Lazy Loading, Memoization, Debouncing, Throttling, Virtualization, Code Splitting, and Caching to build blazing fast React apps in 2026.
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.