Puneet Sharma - Frontend Developer & UI Engineer
Puneet Sharma
Frontend Dev & UI Engineer · 16+ yrs · pixel-perfect HTML, React & WordPress

10 Admin Table & Modal Snippets: Sticky Columns, Bulk Edit, and Stacked Dialogs Done Right

10 free admin table & modal snippets: sticky column pinning, bulk cell editing, shift-click select, stacked modals, and real undo/redo history.
10 Admin Table & Modal Snippets

An admin data table looks simple from the outside — rows, columns, maybe a checkbox. What's actually hard is everything that happens once real users start pushing on it: pinning two columns and expecting them to stack without overlapping, shift-clicking a range the way Gmail trained them to, editing a dozen scattered cells and wanting one Save instead of a dozen. None of that is exotic — it's well-understood behavior with one correct implementation, and getting the details slightly wrong (a hardcoded sticky offset, an undo stack that's really just a boolean) is what makes homemade admin tools feel subtly broken compared to Sheets, Gmail, or a real desktop app.

Below are ten free, self-contained snippets covering exactly that class of problem — sticky columns, dynamic show/hide, density switching, inline bulk edit, expandable rows, anchor-based range selection, a pick-your-columns export, a real draggable/resizable window, a modal stack, and a proper undo/redo history. Every one is a live, interactive preview you can try right here in the article, and each exports to React, Vue, Angular or Tailwind in one click from its snippet page.

What these ten get right

  • Recalculate from scratch, never patch incrementally. The column-pin toggle's recalculateStickyOffsets() walks every column left to right and rebuilds every sticky left value from a running total on every single change — which is what guarantees pinning or unpinning columns in any order always produces a correct, non-overlapping stack, instead of a bespoke delta calculation that only worked for the one sequence it was tested with.
  • One shared attribute keeps a header and its whole column in lockstep. The column-visibility menu, the pin toggle, and the export selector all link a control to an entire column — header cell and every row's cell — through one common data-col/data-key value and a single query, so a header can never end up hidden while its body cells still show, or vice versa.
  • Dirty state belongs on the individual unit that changed, not the whole record. The inline bulk-edit table tracks a Map of original values keyed per cell, not per row — so "3 unsaved changes" can mean three different fields in three different rows, matching how someone actually corrects a spreadsheet-like table rather than forcing an all-or-nothing row edit mode.
  • An anchor is not the same thing as "the last thing clicked." The Gmail/Sheets-style range selector tracks an anchor that updates on a plain click or a Cmd/Ctrl-click, but deliberately not on a Shift-click — which is exactly what lets repeated Shift-clicks recalculate a range from a stable point instead of accidentally chaining forward from wherever the last Shift-click landed.
  • A stack of DOM elements beats a boolean the moment nesting is possible. The stacked modal manager and the undo/redo toolbar both replace a single "is it open" flag with an actual ordered array — of open modals, or of full state snapshots — because only an array can answer "which one exactly" and "what's now on top," which every subsequent operation depends on.

1. Table Column Visibility Toggle Menu

A dropdown checklist that genuinely adds and removes entire table columns — header and every row's cell together — not a menu that just looks like it does something.

How it works: every optional column's header and every row's matching cell share one data-col value, and so does the checkbox controlling it. applyVisibility() runs a single table.querySelectorAll('[data-col="' + col + '"]') scoped to the whole table per checkbox — one query that matches the header cell and every body cell for that column at once, toggling a shared .col-hidden class on all of them together. The one column with no data-col attribute (Name) is structurally exempt from the whole system, modeling the realistic rule that a table needs at least one identifying column always visible — and the visible-column counter explicitly adds 1 to both sides of its ratio to account for that always-on column.

Best for: admin user/order tables and CRM-style views where different users care about different fields. Tip: persist each checkbox's checked state to localStorage on change and restore it before the first applyVisibility() call to make column choices survive a reload.

Grab the code: Table Column Visibility Toggle Menu

2. Table Row Density Toggle

A three-way Compact/Comfortable/Spacious switch, built as a real accessible radio group, that changes only vertical padding and font-size and persists the choice across reloads.

How it works: each density mode's CSS touches exactly two properties — padding and font-size — and nothing about column widths or visible data ever changes between modes, keeping density purely about vertical rhythm rather than information density. The three buttons live inside a container with role="radiogroup", each carrying role="radio" and a live aria-checked, since a density choice is inherently mutually exclusive — exactly the semantic a radio group communicates that three unrelated buttons wouldn't. The chosen density is written to localStorage and read back on load, both wrapped in try/catch (storage can throw in private browsing) and validated against the three known-good values before being trusted, falling back to "comfortable" if the stored value is missing or corrupted.

Best for: support-ticket queues and any data-heavy admin table where power users want to scan more rows while casual users want breathing room. Tip: give each independently-togglable table on a page its own STORAGE_KEY so their density preferences don't collide.

Grab the code: Table Row Density Toggle

3. Table Column Pin/Unpin Toggle

Click a pin icon on any header and that column sticks to the left edge while the rest scrolls underneath — and unlike a single hardcoded frozen column, any number of columns can be pinned and they stack correctly in order.

How it works: CSS position: sticky needs an explicit left offset, and that offset is different for every pinned column depending on how wide every other already-pinned column is. recalculateStickyOffsets() walks every column left to right, and for each pinned one assigns left equal to a running total of every prior pinned column's actual rendered width — recalculated fully from scratch on every pin, unpin, and window resize, rather than patched incrementally. Every <td> in a column is queried alongside its header via a shared data-col attribute, so a whole column pins and unpins as one atomic visual unit.

Best for: wide financial and reporting grids where users need Excel-style freeze-panes, but per-user and per-column rather than one fixed developer choice. Tip: serialize the pinnedCols Set to localStorage on every change to persist a user's pinned columns across sessions.

Grab the code: Table Column Pin/Unpin Toggle

4. Table Export with Column Selector

An Export panel where the user picks exactly which columns to include and whether to generate CSV or JSON — reading live from the rendered table so the export always matches what's actually on screen.

How it works: collectRows() reads directly from the rendered <table>'s tbody rows via each cell's data-key attribute rather than a separate JavaScript data array — so if the table is later hooked up to sorting or filtering, the export automatically reflects whatever is currently visible with zero extra wiring. toCsv()'s escaping function checks every cell for a comma, quote, or newline and only then wraps it in quotes with internal quotes doubled — the standard CSV escaping rule, applied unconditionally rather than treated as an edge case a naive .join(',') would silently corrupt. A separate COLUMN_LABELS map keeps human-readable export headers decoupled from the internal data-key names used for both the checkboxes and the DOM queries.

Best for: finance and reconciliation exports where a team needs specific column subsets, not the whole schema. Tip: swap the exportPreview.textContent assignment for a Blob plus a temporary download-attribute anchor to trigger an actual file download instead of an on-page preview.

Grab the code: Table Export with Column Selector

5. Inline Cell Bulk-Edit Table

Every cell is directly editable via contenteditable, and a floating save bar counts unsaved changes across the whole table — not per row — letting you fix scattered fields and commit them all in one batch.

How it works: every editable cell's original text is captured into a Map keyed by the DOM node itself on load. Every input event compares the live text back against that stored original — if a user types something and then types it back, the dirty flag clears automatically, since the comparison is always against the true original rather than "has this cell ever been touched." updateBar() counts .cell.dirty across the entire table, which is why "3 unsaved changes" naturally means three fields scattered across however many rows, matching how someone actually works through a table rather than forcing a whole-row edit mode. Enter is intercepted to blur the cell instead of inserting a line break, since these are single-line values.

Best for: internal product-catalog tools where an ops team corrects prices or stock counts across many SKUs without opening a modal per row. Tip: inside the Save handler, collect each dirty cell's closest('tr[data-row]') id and data-field before clearing dirty state, and send the whole batch as one PATCH request.

Grab the code: Inline Cell Bulk-Edit Table

6. Expandable Row Detail Table

Click any row and a full-width detail panel unfolds beneath it with line items and metadata — inline drill-down with no modal and no navigation away from the list.

How it works: each record is genuinely two <tr> elements — a summary row and a detail row containing one full-width <td colspan> — linked by matching data-row/data-detail-for values rather than assumed DOM adjacency, so the toggle looks up the correct detail row explicitly even if the table structure changes around it. The summary row carries a real, live aria-expanded state kept in sync with the detail row's hidden attribute (the native boolean attribute, not a CSS class) in the exact same click handler — and a single delegated click listener on the table means rows added later, e.g. from pagination, work immediately with no extra wiring.

Best for: order-management and transaction tables where a summary list needs to stay scannable but full line-item detail should be one click away. Tip: lazy-load a row's detail content via a fetch call the first time it's expanded instead of rendering every detail panel upfront.

Grab the code: Expandable Row Detail Table

7. Shift-Click Range Select in a Table

Genuine Gmail/Sheets-style selection: plain click selects one row, Shift-click selects the whole range to it, Cmd/Ctrl-click toggles a single row without disturbing the rest.

How it works: anchorIndex tracks the last row clicked without the Shift modifier — not simply "the last row clicked." That distinction is the whole trick: click row 2, Shift-click row 6 (selects 2–6), then Shift-click row 4 — the range recalculates as 2–4, not "extend from 6 to 4," because the anchor never moved during either Shift-click. selectRange() clears the selection before filling the new range, since Shift-click defines a fresh contiguous range rather than adding to an existing one — that's what Cmd/Ctrl-click is for — and unlike Shift-click, it deliberately does move the anchor, letting the two modes compose the way they do in a real file manager. Every row's checkbox has pointer-events: none, rendered purely as a reflection of a selected Set rather than an independent control, so the two can never fall out of sync.

Best for: any admin list, inbox, or file-browser grid where users expect their OS's native multi-select conventions to just work. Tip: pair this with a long-press-to-enter-selection-mode pattern for touch devices, where Shift/Ctrl modifiers don't exist.

Grab the code: Shift-Click Range Select in a Table

8. Resizable, Draggable Floating Modal Window

A real desktop-style panel — drag it by its title bar, resize it from any edge or corner — with correctly clamped minimum size and edge-anchored resizing so the opposite side never jumps.

How it works: both drag and resize call setPointerCapture(e.pointerId) on pointerdown, which routes every subsequent pointer event to that same element even once the pointer strays outside a narrow 6px resize handle mid-gesture — without it, a fast drag would silently stop tracking the instant the cursor left the strip. Every resize calculation measures against the drag's fixed starting snapshot (resizeState.startW + dx), never accumulated frame by frame, avoiding drift over a long gesture. Dragging the west or north edge updates both size and position together — the new width is clamped first, then left is derived from exactly how much that clamped width differs from the start, which is what keeps the window's opposite edge perfectly stationary even once the minimum-size clamp kicks in. One shared resize routine checks which characters appear in a handle's data-dir string, so the corner handle needs zero special-case code — it simply combines its two adjacent edges' logic automatically.

Best for: floating notes panels, debug overlays, or live-preview windows that shouldn't block the content behind them. Tip: store win.style.top/left/width/height to localStorage on pointerup so the window reopens exactly where a user left it.

Grab the code: Resizable, Draggable Floating Modal Window

9. Stacked Modal Manager

Open a modal from inside another modal — and a third from inside that — with each backdrop compounding correctly, Escape closing exactly one layer per press, and focus always landing somewhere sensible.

How it works: stack is a real array of the open overlay DOM elements in open order, not a boolean or a counter — closing needs to know exactly which modal to remove (always stack.pop()), and a counter alone couldn't answer that. The keydown listener calls closeTopModal() — singular — on every Escape press, deliberately never clearing the whole stack at once, since a nested "discard changes?" confirmation exists specifically to be seen before the user is returned to what's beneath it. Each overlay's z-index is set to 50 + depth at creation time, so stacking order always stays derived from — and therefore consistent with — the actual array order, and each layer's own semi-transparent backdrop compounds visually with the ones below it for a natural progressive dim with zero manual opacity math.

Best for: a destructive action inside one modal opening a confirmation on top of it, or an admin tool that needs a drill-down detail modal launched from inside a list modal. Tip: add a proper focus trap within each individual layer so Tab cycles only within the topmost modal's own focusable elements.

Grab the code: Stacked Modal Manager

10. Undo/Redo History Toolbar with Jump-to-State

A genuinely correct undo/redo system — full state snapshots plus a single pointer, with a history panel letting you jump straight to any prior point, not just step one at a time.

How it works: rather than storing each action as an inverse operation (which requires writing and maintaining a correct "undo" for every distinct action type), the whole system is an array of full state snapshots plus a single pointer index. undo() and redo() do nothing but move that pointer and call render(), which simply displays whatever snapshot lives at history[pointer] — trivially correct by construction no matter how the state got there. pushState() calls history.slice(0, pointer + 1) before appending, discarding the abandoned "future" branch the instant a new action happens after an undo — without this, stale future states would linger and redo could jump to something inconsistent with what's actually happened since. Clicking any entry in the history list is not a special case at all: it sets pointer directly and calls the identical render() that a single undo or redo step uses.

Best for: canvas/drawing tools, form builders, and any editor where "just show me what it looked like N steps ago" beats hand-writing an inverse for every action. Tip: for high-frequency actions like typing, coalesce rapid successive changes into one history entry (committing only after a pause) rather than pushing a snapshot per keystroke — the pointer/array pattern underneath stays identical either way.

Grab the code: Undo/Redo History Toolbar with Jump-to-State

How to drop these into your project

  1. Open any snippet and hit View / Edit Code to see the HTML, CSS and JS in separate tabs, then paste the HTML into your markup, the CSS into your stylesheet, and the JS before your closing </body> tag — or use the one-click export to React, Vue, Angular or Tailwind right from the snippet page. None of these ten need anything installed — no table/grid library, no modal library, pure vanilla JS.
  2. Recompute derived layout state from scratch, don't patch it. The pin toggle's sticky offsets and the modal stack's z-index both rebuild themselves entirely from current state on every change — resist the temptation to write an incremental "just adjust the delta" version, since it's the source of most of these patterns' subtle bugs in hand-rolled versions.
  3. Give every control-and-target pair one shared identifying attribute. Visibility, pinning, and export selection all link a checkbox/button to an entire column purely through a matching data-col/data-key value queried once — that single shared key is what makes header and body cells impossible to desynchronize.
  4. Reach for an array the moment "more than one at a time" becomes possible. A boolean or counter answers "is something open" or "how many," but not "which one" or "what's now on top" — the stacked modal manager and the undo history both need exactly that, which only an ordered array provides.
  5. Use Pointer Events with setPointerCapture for any drag or resize handle. A plain mousemove listener silently stops tracking the moment a fast gesture outruns a narrow handle's bounds; pointer capture keeps every subsequent event routed to the same element regardless of where the cursor strays.

Final thought

None of these ten snippets are visually flashy — there's no particle system or WebGL shader in the batch. What they share is that each one is a piece of interaction behavior users already have strong, specific expectations for, learned from Excel, Gmail, Sheets, and every desktop OS they've ever used — and those expectations have one correct underlying implementation, not several equally valid ones. A sticky column that doesn't recalculate its offset from scratch, a range-select that treats "last clicked" as the anchor, an undo system built from per-action inverses instead of snapshots — each of those shortcuts looks fine in a quick demo and then breaks in exactly the case a real user will eventually hit. Get the actual pattern right once, as these do, and it holds up under arbitrary nesting, arbitrary column counts, and arbitrary edit sequences with no special-casing required.

Try them, retune them to your own data, 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.

About the author

Puneet Sharma
Puneet Sharma is a freelance web developer and the creator of FWD Tools and WebDevPuneet. Follow him on X/Twitter

Post a Comment