The moment a project needs to show a list of records, someone suggests installing a data grid. It's an understandable reflex — sorting, filtering, pagination, selection and export sound like a lot of work — but the honest version of most requirements is "a table with three or four of those features," and a grid library answers it with a large dependency, its own data conventions, and a theming system you'll spend a day fighting. Below are 10 free table snippets you can preview live and copy in seconds: a complete admin table, click-to-sort headers, per-column filters, client-side pagination, frozen header and first column, row selection with a bulk action bar, inline cell editing, expandable detail rows, a correct CSV export, and a real pivot table.
Each one comes with how it actually works, when to reach for it, and a tip for adapting it. They're all plain HTML, CSS, and vanilla JavaScript over a real <table> — no grid library, no virtual DOM, no build step.
Every snippet below is a live, interactive preview — sort it, filter it, page through it, right in the article. When you find one you like, hit View / Edit Code to grab the HTML/CSS/JS or export it to React, Vue, Angular or Tailwind.
What makes a table usable rather than just present
Tables fail in a small number of predictable ways, and each snippet below fixes at least one of them:
- Sort and format are two different things. A column showing "$89.99" is text to a computer. If the raw value isn't kept somewhere the code can reach, sorting silently produces nonsense — and nobody notices until a customer does.
- State belongs in data, not in the DOM. Which rows are selected, which page you're on, which columns are visible — the snippets that survive real use keep those in a variable and re-derive the markup from it. The ones that read checked-ness back out of the DOM drift out of sync the first time anything re-renders.
- The screen has edges. Wide tables scroll sideways and long ones scroll down, and both lose the context that made the numbers meaningful. Sticky headers, frozen columns and pagination all exist for the same reason.
Keep those three in mind as you scroll.
1. Data Table
The complete admin table you'd otherwise assemble from four separate tutorials: live search with a result count, status badges, row checkboxes with select-all, gradient avatar initials, Edit and Remove row actions, and a styled pagination footer.
How it works: the search is deliberately naïve in the best way — for each row it reads row.textContent, which returns every visible character in that row as one string, and checks whether the lowercased search term appears in it. One includes() matches name, email, role, department and status simultaneously, with no per-column logic and no need to touch the function when you add a column. Non-matching rows get a class that sets display: none, and the same pass counts the survivors for the "Showing 3 of 5" line in the footer. The avatars are initials on a CSS gradient rather than images, so there's nothing to load and nothing to break when a URL 404s.
Best for: the user, order or record list at the centre of any admin panel — the screen you build first and then keep adding to. Tip: that textContent search is perfect up to a few hundred rows and quietly wrong beyond them, because it scans every row on every keystroke. Past that point, debounce the input by ~150 ms before you reach for anything cleverer. The pagination footer here is styled markup rather than working paging — drop in the logic from snippet 4 below when you need it to do something.
Grab the code: Data Table
2. Sortable Table
Click a header to sort ascending, click again for descending, with an arrow indicator on the active column — and correct numeric ordering on columns that display currency and units.
How it works: each sortable header carries data-col (which column index) and optionally data-type="number", and the handler reads the header's current class to decide the next direction. Rows are spread into an array, sorted, and re-appended — the DOM order is the sort result, so nothing needs re-rendering. The important part is the data-val attribute on formatted cells: a cell reading "$89.99" also carries data-val="89.99", because parseFloat("$89.99") returns NaN and a table sorted on NaN just shuffles. The direction arrow is a CSS ::after driven by the header's class, so JavaScript never touches it.
Best for: any table where "cheapest first" or "newest first" is a question users will ask — which is nearly all of them. Tip: the data-val pattern generalises further than prices. Put an ISO date in it for a column showing "14 Mar", or a rank number for a column showing badges, and the same comparison sorts them correctly.
Grab the code: Sortable Table
3. Filterable Table
A filter input in every column header, combined with AND logic, plus match highlighting, a numeric minimum filter, a live result count and a clear-all button.
How it works: each header input writes into a filters array indexed by column, and a row survives only if it satisfies every active filter — which is the whole point, because "Engineering and Berlin" is a question a single search box cannot express. Text columns match case-insensitive substrings; the salary column is treated as a minimum instead, since nobody searches a salary for the characters "100000". Matches are wrapped in a <mark>, and the order there matters: the cell text is HTML-escaped first and only then is the matched slice wrapped, so a value containing < or & can never inject markup.
Best for: reporting screens and directories — anywhere users arrive knowing two or three things about the record they want. Tip: the count, the empty state and the clear button look like polish and aren't. Without them a user who filters down to zero rows sees a blank table and assumes it's broken.
Grab the code: Filterable Table
4. Pagination Table
Client-side paging done properly: smart page numbers with an ellipsis for the gaps, prev/next buttons that disable at the ends, a rows-per-page selector, and a "Showing 1–10 of 48 results" info bar.
How it works: the full dataset lives in an array and the render pass slices it — start = (page - 1) * perPage, then data.slice(start, end) — rebuilding the body and the controls together on every change. The page buttons are the part most hand-rolled paginations get wrong: rather than printing every page number, it shows the first page, the pages either side of the current one, and the last page, running the result through a Set to deduplicate and dropping an ellipsis into the gaps. Changing the rows-per-page selector also resets to page 1, so you can never be left stranded on a page number that no longer exists.
Best for: any list past a couple of screens, and the default choice over infinite scroll whenever users need to find a specific record rather than browse. Tip: moving to server-side paging changes surprisingly little — swap the slice() for a fetch with page and limit parameters and compute the button range from the total count the API returns.
Grab the code: Pagination Table
5. Sticky Header Table
Frozen panes, the spreadsheet feature every wide table needs: the header row pinned to the top and the first column pinned to the left, both staying put while the rest scrolls in two directions — with no JavaScript at all.
How it works: three position: sticky rules inside a scrolling container — top: 0 on the header cells, left: 0 on the first column, and both on the corner cell that belongs to each. Stacking order is what makes or breaks it: the header sits at z-index: 2, the frozen column at 1, and the corner at 3 so it stays above both where they cross. Each sticky cell also needs an opaque background, or scrolled content shows straight through it. And the whole thing uses border-collapse: separate, because sticky positioning is unreliable with collapsed borders — those belong to the table rather than the cells — so row dividers are drawn per cell instead.
Best for: wide financial, analytics and comparison tables where losing the column heading means losing the meaning of the number you're looking at. Tip: freeze exactly one column. Two feels like control and leaves phone users a sliver of scrollable width.
Grab the code: Sticky Header Table
6. Selectable Table
Row selection with the details that make it feel professional: a tri-state select-all header, shift-click to grab a range, whole-row click targets, a live count, and a bulk action bar that appears only when something is selected.
How it works: the selection lives in a Set and everything visible is derived from it in one refresh pass — the row highlights, the checkboxes, the count, and the header's state. That header checkbox is checked when all rows are selected, indeterminate when only some are, and empty when none are; skip the middle state, as most hand-built tables do, and a half-selected table shows a fully-ticked header, which is simply a lie. Shift-click remembers the last index clicked and adds the whole range between it and the new row, the same gesture your file manager uses.
Best for: any table with bulk operations behind it — delete, export, assign, tag. Tip: this demo stores row indices in the Set, which is fine for one static page and breaks the instant rows are sorted or paginated, because index 2 is now a different record. Store stable ids instead before you ship it. If you want the floating toolbar as its own component, the Bulk Actions Bar snippet isolates that half.
Grab the code: Selectable Table
7. Editable Table
Spreadsheet-style inline editing: every cell is a text field that looks like plain text until you click it, with add-row, delete-row, a live row count and a save button.
How it works: the trick is entirely in the CSS. Each cell holds a real <input> with a transparent border and background, so it reads as text; on hover a faint border appears, and on focus a full indigo border with a soft shadow ring. Nothing is swapped in or out of the DOM, which is why there's no flicker and no lost keystroke when you click straight from one cell into another. The data binding is deliberately plain: each input writes back to rows[i][key] on input, and the table re-renders from that array, so the array is always the truth and there's no two-way binding machinery to reason about. A new row slides in with an animation and auto-focuses its first cell.
Best for: settings tables, team and pricing management, CMS field editors — anywhere users change several small values in one sitting and a modal per row would be torture. Tip: add a "saved" indicator or an autosave debounce. An explicit save button is honest, but a table full of edits and no feedback makes people re-check every field before they trust it.
Grab the code: Editable Table
8. Expandable Rows Table
Summary rows that open to reveal their detail — click an order and its line items unfold underneath, with a rotating chevron and accordion behaviour so only one is open at a time.
How it works: every visible row is paired with a hidden detail row directly beneath it, tied together by the row’s data-id and a matching id on its detail row. Toggling adds a class to both; the detail row's CSS flips from display: none to display: table-row, which is the specific value that keeps it inside the table's layout rather than collapsing it into a block. Before opening one, the handler closes any that are open — accordion behaviour, and deleting that block is all it takes to allow several at once. The detail cell spans every column with colspan and holds a nested table with its own header, indented behind an accent left border so the hierarchy is visible at a glance.
Best for: orders and their line items, invoices, expense categories, test suites and their cases — any parent record whose detail is too much for a column but too little for its own page. Tip: if you change the outer column count, update the colspan to match, or the detail panel will sit misaligned under a table that otherwise looks fine. For genuinely recursive data rather than two fixed levels, the Tree Table handles unlimited nesting.
Grab the code: Expandable Rows Table
9. CSV Export Table
The "Export to CSV" button every table eventually needs, implemented correctly: RFC-4180 quoting, a UTF-8 BOM so Excel doesn't mangle accents, a dated filename, and no server round-trip.
How it works: one small function does the load-bearing work. A value is wrapped in double quotes, with its own quotes doubled, only when it contains a comma, a quote, or a newline — exactly the RFC-4180 rule — so Marco "Cosmo" Rossi exports as "Marco ""Cosmo"" Rossi" and an address with a comma in it stays in one column instead of spilling into the next. The file then gets a UTF-8 byte-order mark prepended, which is the one-character difference between Excel showing "Café" and showing "Café". The string becomes a Blob, an object URL, and a programmatically clicked <a download> — the data never leaves the device.
Best for: any admin screen where someone will eventually say "can I get this in a spreadsheet?" — which is every admin screen. Tip: this exports the rows currently in the page's data. Once your table is paginated or filtered server-side, that silently becomes "export page 1 of 40", so point the button at an endpoint that streams the full dataset through the same escaping rules.
Grab the code: CSV Export Table
10. Pivot Table
The spreadsheet feature people assume needs a BI tool: flat records cross-tabulated by row and column, with switchable sum/average/count measures and correct totals on both axes.
How it works: three constants at the top name which field becomes the rows, which becomes the columns, and which is being measured. The code derives the unique values of each dimension from the data itself, then for every row/column intersection filters the records matching both and aggregates them. Every cell, every row total, every column total and the grand total run through one aggregate() function, which is what keeps the measure switch consistent. And the totals are computed from the raw records, never by adding up the displayed cells — for sum that happens to be the same answer, but averaging a row of averages is mathematically wrong, and getting that right is the line between a pivot and a grid of numbers.
Best for: sales by region and quarter, spend by category and month, survey cross-tabs, inventory by location and type — any moment someone exports to a spreadsheet purely to build a pivot. Tip: the demo filters the whole dataset once per cell, which is clear to read and fine for dozens of intersections. Over thousands of records, group the records once into a map keyed by row-plus-column and read each cell from it instead.
Grab the code: Pivot Table
How to drop these into your project
Every snippet is framework-agnostic, so the workflow is the same wherever you're building:
- Open the snippet and hit View / Edit Code to see the HTML, CSS and JS in separate tabs.
- Paste the HTML into your markup, the CSS into your stylesheet, and the JS before your closing
</body>tag — none of these ten need a CDN script or a build step. - Prefer a component? Use the one-click export to React, Vue, Angular or Tailwind right from the snippet page.
- Replace the demo array (or the sample rows) with your API response. Most of these render from data, so that's the only change between mockup and working screen.
- Combining two is usually trivial — they share the same
thead/tbodystructure, so sorting, searching and pagination can be layered onto the same table without conflicting.
A few table principles worth keeping
- Keep the sortable value next to the readable one. Format for humans in the cell, store the raw number or ISO date in
data-val, and sorting, filtering and export all stay correct for free. - Let one structure own the state. The
Setin the selectable table, the array in the editable table, the column config in a toggle — a single source that the DOM is rendered from, rather than state read back out of the DOM. - Use a real
<table>. Every snippet here does. You get column alignment, screen-reader row and column semantics, andcolspanfor free — all of which a grid of divs has to reimplement badly. - Add features on demand, not in advance. Each of these is a few dozen lines. Install the dependency the day you genuinely need column virtualisation over 50,000 rows, not on the assumption you might.
Final Thought
These ten cover the whole life of a table — show the records, sort them, filter them, page them, keep the headings visible, select and act in bulk, edit in place, drill into detail, export to a spreadsheet, and summarise into a cross-tab. Start with the Data Table and layer on only what your users actually request. When they want more, the same library has a Resizable Columns Table with double-click auto-fit, a Column Toggle for dense grids, an Infinite Scroll Table that lazy-loads pages with an IntersectionObserver sentinel, and a Bar Chart for when the numbers deserve a picture — all dependency-free too.
Preview any of them live, tweak the code, and export to your framework of choice in one click. Browse the full collection at FWD Tools UI Snippets — it's free and runs entirely in your browser.
