ui-023

Sortable Data Table

£10 or the whole Vault, £99

A sortable data table for dashboards: row selection, a bulk-action bar with Undo, filter and pagination, and rows that travel with GSAP Flip.

Flip intermediate
5 more details
reorderflip-layoutfilterkeyboard-navigationstagger

This element reads better at your own screen size than in the frame below:

Updated

About this component

A data table that shows its work. Clicking a header sorts the column, clicking it again reverses it, and the direction arrow rotates rather than swapping glyph. The rows do not blink into a new order: GSAP Flip records where each one was, the rows are re-appended in their new sequence, and every row tweens from its old position to its new one. The sorted column keeps a wash of the accent, which is what makes the movement legible.

Read the full effect overview

Tick a row and a compact bulk-action bar rises into the footer with the selection count, which rolls as you add rows. The header checkbox selects the page and shows an indeterminate state, and Shift+click selects a range. Archive and Delete collapse the chosen rows out while the others close the gap, with an Undo in the bar for a few seconds that brings them back the same way. The filter field and the pager move rows by the same rules: leavers fade where they stood, survivors travel, arrivals fade in, and the body keeps a full page's height so a short last page never jumps.

It mounts with DataTable.mount(element, { pageSize }) and returns sort, filter, select, clearSelection, getSelection and destroy, with datatable:sort, datatable:select and datatable:action events. The markup stays a real table with a caption, scope, aria-sort, labelled checkboxes and a polite live region, readable with the script removed. Vanilla JavaScript, GSAP and the Flip plugin, no framework. Light and dark themes come from one set of custom properties.

Sortable Data Table - UI component preview

What's included

8 items
  • Header buttons sort ascending, then descending, with a rotating arrow; rows travel with Flip
  • Checkbox column with page select-all, indeterminate state and Shift+click range select
  • Bulk-action bar rises in while rows are selected, with a rolling count
  • Archive and Delete collapse rows out with an Undo that restores them
  • Filter and pagination that move rows rather than swapping them, with no height jump between pages
  • DataTable.mount API with sort, filter, select and destroy, plus events
  • Real table markup with caption, scope, aria-sort, labelled checkboxes and a live region
  • Light and dark themes on one set of custom properties

Perfect for

4 use cases
  • Admin and dashboard tables of accounts, orders or users
  • Inbox-style lists where people select rows and archive or delete in bulk
  • Reporting views where sorting, filtering and paging are the main interactions
  • Any table where a re-order or a removal currently happens in one invisible frame

How it works

4 sections

One render, driven by Flip

Sort, filter, page, removal and Undo all change state and call the same render: it records Flip.getState over the rows that stay, re-appends every row in sorted order with the hidden attribute on those off the page, and hands the state to Flip.from. Flip's absolute option is not used, because taking a tr out of the table's layout collapses its columns; rows of equal height travel perfectly well on transforms alone.

Leaving rows and page height

A leaving row is removed from the layout at once so the others start closing the gap, while a copy in a one-row table with the measured column widths fades out where it stood. With more than one page the body keeps a full page's measured height, so the short last page does not pull the pager up, and any other height change is tweened.

Selection and the bulk bar

Real checkboxes stretched over their cells drive a selection set keyed by each row's data-id. Shift ranges follow the current sort and filter order. The bar rises into the footer with a short back.out settle, its count is two stacked numbers that roll past each other, and a removal swaps it for an Undo whose timer is a tween, so hovering or focusing the bar pauses it.

Sort values and the column wash

A cell's data-value wins when it exists, so a formatted number like 14,200 and a relative date like 2d ago both sort as numbers, while text columns sort on what is rendered. The active column's cells take an is-sorted class with the accent at low opacity, and the header's aria-sort is kept in step.

Plugins Flip
Difficulty Intermediate
Includes HTML + JS + CSS source, documentation, AI setup prompt, lifetime updates

Lighthouse, as measured

Google Lighthouse on this UI element's demo, from the latest scan. A measurement of the demo as shipped, not a promise for your page.

Accessibility
100
Best practices
100

No performance score, on purpose. That figure depends on how you deploy: your server's compression and caching, your CDN, the connection and the device doing the test, none of which the code controls. The same page can score very differently on two consecutive runs, so measure it where it will live.

Paid UI element

Purchase to unlock the code.

Buying Sortable Data Table opens the HTML, CSS and JavaScript source, the full documentation, an AI setup prompt for your editor, and every update we ship to it. Standard license: unlimited personal and commercial projects. Every paid product is tested for desktop and mobile layouts, reduced-motion handling, animation cleanup and keyboard controls wherever there is something to operate.

£10 Standard license, unlimited projects

Browse UI elements

Or the whole library: the Vault, £99, one payment.

Documentation

What's Included

  • index.html: the demo page and the markup to copy

  • assets/style.css: component styles, plain CSS custom properties for colours

  • assets/script.js: readable, commented source exposing window.DataTable

  • Header buttons sort ascending, then descending, with a rotating arrow; rows travel to their new places

  • A checkbox column: select-all for the page with an indeterminate state, Shift+click range select, a tint on selected rows

  • A bulk-action bar (count, Export, Archive, Delete, Clear) that rises into the footer while rows are selected, with a count that rolls as the selection changes

  • Archive and Delete collapse the rows out while the others close the gap, with an Undo in the bar for five seconds that brings them back the same way

  • A text filter: rows that no longer match fade where they stood, survivors travel, arrivals fade in

  • Pagination (8 rows a page by default) with previous, next and page numbers; the body keeps a full page's height, so a short last page never jumps the footer

  • Sticky header over a scrolling body, and real table markup with caption, scope and aria-sort kept in step

  • A polite live region announcing sorts, filter counts, pages, selection counts and removals

  • A drop-in API: DataTable.mount(el, options), any number of tables per page, events for every change, destroy() that puts the markup back

  • Light and dark themes on one set of custom properties

Quick Start

1. Add to your HTML <head>:

Code snippet omitted: it ships with the download.

2. Add to your <body>:

Copy the whole .tbl block from index.html. A header cell holds a button naming the column and its type:

Code snippet omitted: it ships with the download.

Every row carries a stable data-id (what selection, events and the API talk about), and every body cell names the column it belongs to, with data-value when what is rendered is not what should be sorted:

Code snippet omitted: it ships with the download.

The checkbox column, the footer (range, bulk bar, pager) and the live region are added by the script, so the markup stays a plain, readable table without JavaScript.

3. Add before the closing </body> tag, and mount:

Code snippet omitted: it ships with the download.

Or skip the script block and let it mount itself: <section class="tbl" data-datatable data-page-size="8">. Markup from the earlier version (<section class="tbl" id="tbl">) still mounts on its own too; rows without a data-id are given row-1, row-2 and so on.

API

Code snippet omitted: it ships with the download.

Option Default What it does
pageSize 8 (or data-page-size) Rows per page. 0 turns pagination off
actions Export, Archive, Delete The bulk actions: [{ id, label, removes, done, tone }]. removes: true collapses the rows out with Undo; done is the past tense for the message ('archived'); tone: 'danger' paints the label
undoMs 5000 How long Undo stays on offer before a removal is committed
selectable true false leaves out the checkbox column and the bulk bar
noun ['row', 'rows'] The word in the range, the bar and the announcements (['account', 'accounts'])
Method What it does
sort(key, dir) Sort by a column key. dir is 'ascending'/'descending' (or 'asc'/'desc', 1/-1); leave it out to toggle like a click. sort(null) returns to the authored order
filter(query) Filter as if typed into the field (and puts the text in the field). filter('') clears it
page(n) Go to page n, counted from 1
select(ids) Replace the selection with these data-ids
clearSelection() Clear the selection; the bar leaves
getSelection() The selected ids, in table order
refresh() Re-read the <tbody> after you add or remove <tr> elements yourself
destroy() Remove everything the script added, put every attribute and row order back, remove every listener. Rows still waiting on Undo come back; committed removals stay removed

Events bubble from the root element:

Event detail
datatable:sort { key, direction, user }
datatable:select { ids, count }
datatable:action { action, ids, phase }: phase is 'request' when the button is pressed, then 'undo' or 'commit' for a removal. Cancelable on 'request': call preventDefault() to stop the built-in removal and handle it yourself
datatable:filter { query, count, user }
datatable:page { page, pageCount, user }

Wiring Delete to a server is the commit phase, so a person who presses Undo never costs a request:

Code snippet omitted: it ships with the download.

Rendering rows from data

The table reads its rows from markup, so render the <tr> elements first and mount afterwards:

Code snippet omitted: it ships with the download.

When the data changes later, append or remove <tr> elements and call table.refresh(): new rows get their checkbox, rows that are gone leave the selection. For a full reload, table.destroy(), replace the rows, and mount again. In React or Vue, render the rows from state, mount in an effect after the first render, and destroy in its cleanup.

How It Works

One render for everything. Sort, filter, page, removal and Undo all change state and then call the same render: work out which rows belong on screen, record Flip.getState over the rows that stay, re-append every row in sorted order with the hidden attribute on the ones off the page, and hand the state to Flip.from. Flip's absolute option is deliberately not used: taking a <tr> out of the table's own layout collapses its columns, and rows of equal height travel perfectly well on transforms alone.

Leaving rows. A row that leaves (filtered out, archived, turned over to the next page) is removed from the layout at once, so the others start closing the gap immediately. What you see fading out is a copy: a one-row table placed over the old position with the measured column widths, which fades (and, on a page change, slides a few pixels in the paging direction) and is then removed. Arriving rows fade in a moment later, once the travellers have mostly settled.

Page height. With more than one page, the scrolling body keeps the height of a full page as a min-height, measured from a real full page, so the short last page does not pull the pager up under the pointer. Any other height change (a filter leaving two rows) is tweened rather than snapped.

Selection. Checkboxes are real <input type="checkbox"> elements stretched over the cell, with a drawn face beside them. Shift+click (or Shift+Space) selects or clears every row between the last checkbox you used and this one, in the current sort and filter order, across pages. The header checkbox acts on the visible page and shows indeterminate when only some of it is selected. Filtering drops rows that no longer match from the selection, so a bulk Delete only ever acts on rows the person can find.

The bar. It lives in the footer's left slot, clipped, and rises with a short back.out(1.3) settle while the range text fades out. The count is two stacked numbers: the old one leaves upward and the new one arrives from below (downward when the count drops). A removal swaps the bar's contents for "4 rows archived" and Undo, with a thin accent line running down the Undo window; the timer is a tween, so hovering or focusing the bar pauses it.

Sort values and the column wash. A cell's data-value wins when it exists, so a formatted number like 14,200 and a relative date like 2d ago both sort as numbers, while text columns sort on what is rendered. Equal values keep their authored order. The cells of the active column take an is-sorted class that paints them with the accent at low opacity.

Using It With Your Own Data

The 44px strip across the top of index.html is demo furniture: the theme toggle and two shortcuts into the component's own API, not part of what you copy. Take the .tbl block and leave the strip behind.

The component needs a real <table> with a <thead> of buttons carrying data-key and data-type, and a <tbody> whose rows carry data-id and whose cells carry data-col. The filter field (.tbl-input, .tbl-clear), the count (.tbl-count) and the empty line (.tbl-empty) are optional. Everything else, the coloured dot, the plan tag, the caption text, is dressing you can change or drop.

The non-obvious CSS the script depends on:

  • tbody tr[hidden] { display: none } is restated so the hidden attribute beats the row hover rule.
  • .tbl-scroll is position: relative, because the fading copies of leaving rows are placed inside it.
  • The arrow span and the bar carry no CSS transform, because GSAP moves them. A CSS rotate would be read back as a starting angle.
  • th { position: sticky; top: 0 } needs an opaque background of its own, or rows will scroll through it.
  • The header cell's padding is zero and the button carries it instead, so the whole header cell is the hit target.

For server-side sorting or paging, listen for datatable:sort or datatable:page, fetch, replace the rows and call refresh().

Themes

The component ships in two themes, light and dark: one design at two token values. Pick one with the attribute, because the ground colour is part of it:

Code snippet omitted: it ships with the download.

Nothing else changes: same markup, same script, and nothing in assets/script.js reads the theme name. The demo's toolbar toggle and the ?variant=light URL parameter only set that attribute.

Every colour, radius and shadow is a custom property in the two body[data-variant="..."] blocks at the top of assets/style.css:

Group Properties
Ground and ink --ground, --ground-2, --raised, --hover, --ink, --ink-2, --ink-3
Hairlines --line, --line-strong
Accent --accent, --accent-ink (text on it), --accent-text (the accent as text), --accent-wash
Shape and type --font, --radius, --radius-sm, --radius-xs, --shadow
The table's surfaces --head-bg, --cell-active-bg, --row-hover-bg, --row-selected-bg
Selection and actions --check-border (an empty checkbox), --danger-text (the Delete action)
Plan tag --tag-scale-*, --tag-growth-*, --tag-starter-*
Account dots --dot-1 … --dot-6

To wear your own brand, re-value --accent and --accent-text (the accent as text, so it needs 4.5:1 on your surface); to sit on your own surfaces, re-value --ground, --raised, --head-bg and --line. Give text a solid colour rather than a translucent one, or its contrast cannot be measured.

Customisation

What Where
Re-order speed and feel duration: 0.5 and power2.inOut on Flip.from in render
Row-by-row cadence stagger: 0.012 on Flip.from in render
Leaving rows ghostOf: opacity, x and duration: 0.2
Arriving rows the entering tween in render
The bar's rise back.out(1.3) and duration: 0.34 in setBar
The count roll rollTo
Arrow spin rotation and back.out(2) in setHeaderState
Actions, page size, Undo window the actions, pageSize and undoMs options
Selected row tint --row-selected-bg in the theme blocks
Column wash strength --cell-active-bg in the theme blocks
Filter rule r.textContent.toLowerCase().indexOf(query) in matching
Visible table height max-height on .tbl-scroll (default 380px)
Colours, radii, shadows the two body[data-variant] blocks in assets/style.css

Keyboard & Accessibility

Key What it does
Tab / Shift+Tab Moves through the filter field, its clear button, the select-all checkbox, every column header, each row's checkbox, then the bulk bar and the pager
Enter / Space On a header, sorts that column; again reverses it. On a pager button, changes page and keeps focus on it
Space On a checkbox, selects or clears that row (or the whole page, on the header checkbox)
Shift+Space / Shift+click Selects or clears the range from the last checkbox used
Escape In the filter field, clears the filter. Anywhere else in the table, clears the selection
  • Real table markup with a caption, scope="col" on every header and aria-sort updated on every sort.
  • Every checkbox is labelled: "Select all rows on this page" and "Select Northwind Traders" (the row's first cell, or its data-label).
  • The bar is a region named "Bulk actions". After Archive or Delete, focus moves to Undo; after Undo, to the first restored row's checkbox; when the bar leaves with focus inside it, focus goes to the select-all checkbox, never to the page.
  • The pager is a <nav aria-label="Pagination">; the current page has aria-current="page", and the ends use aria-disabled rather than disabled so focus is not dropped on the last press.
  • A polite live region announces "Sorted by Seats, ascending", "3 rows match" (after typing pauses), "Page 2 of 2, rows 9 to 14", "4 rows selected", "4 rows archived. Undo is available for 5 seconds." and "4 rows restored".
  • The Undo window pauses while the pointer or focus is in the bar.
  • Hidden rows use the hidden attribute, so they leave the accessibility tree as well as the layout; the fading copies are aria-hidden and inert.
  • Without JavaScript the table is fully readable in its authored order, with no checkboxes and no pager.
  • prefers-reduced-motion: reduce sets every movement to zero and hides the Undo countdown line; the Undo window itself still runs.
  • On a phone the table scrolls sideways inside its card, the filter stacks above the count, and the footer puts the bar on its own row above the pager.

Requirements

  • GSAP 3.12+ and the Flip plugin
  • No build step, no framework

Worked examples, the events and programmatic API, and the class reference ship with the download, alongside the full source.