Sortable Data Table
A searchable table with click-to-sort columns, status pills, pagination and an empty state. Accessible and mobile-friendly.
Preview and code
<!--
MIT License
Copyright (c) 2026 OwnlyPlans
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-->
<div class="dt">
<div class="dt-bar">
<label class="dt-search"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
<input id="dt-q" type="search" placeholder="Search customers" aria-label="Search customers" autocomplete="off"></label>
<span class="dt-count" id="dt-count" aria-live="polite"></span>
</div>
<div class="dt-scroll"><table>
<thead><tr id="dt-head"></tr></thead>
<tbody id="dt-body"></tbody>
</table></div>
<div class="dt-pager"><button id="dt-prev" type="button" aria-label="Previous page">‹</button><span id="dt-page"></span><button id="dt-next" type="button" aria-label="Next page">›</button></div>
</div>
/*
* MIT License
*
* Copyright (c) 2026 OwnlyPlans
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
.dt{width:min(520px,100%);background:var(--stage);border:1px solid var(--line);border-radius:14px;overflow:hidden;font-size:13px;color:var(--ink);box-shadow:0 18px 50px -24px rgba(0,0,0,.3)}
.dt-bar{display:flex;align-items:center;gap:10px;padding:10px 12px;border-bottom:1px solid var(--line)}
.dt-search{flex:1;display:flex;align-items:center;gap:8px;padding:6px 10px;border:1px solid var(--line);border-radius:9px}
.dt-search:focus-within{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 20%,transparent)}
.dt-search svg{width:15px;height:15px;flex:none;fill:none;stroke:var(--muted);stroke-width:2.2;stroke-linecap:round}
.dt-search input{flex:1;min-width:0;border:0;background:none;color:var(--ink);font:inherit;outline:none}
.dt-search input::-webkit-search-cancel-button{display:none}
.dt-count{color:var(--muted);font-size:12px;white-space:nowrap}
.dt-scroll{overflow-x:auto}
table{width:100%;border-collapse:collapse;min-width:440px}
th,td{padding:8px 12px;text-align:left;white-space:nowrap}
th{padding:0;border-bottom:1px solid var(--line);font-size:11.5px;font-weight:600;color:var(--muted)}
th button{display:flex;align-items:center;gap:4px;width:100%;padding:8px 12px;border:0;background:none;color:inherit;font:inherit;text-transform:uppercase;letter-spacing:.04em;cursor:pointer}
th button:hover,th button:focus-visible{color:var(--ink);outline:none}
th button:focus-visible{box-shadow:inset 0 0 0 2px var(--accent)}
th[aria-sort] button{color:var(--ink)}
th .arw{opacity:.35;font-size:10px}
th[aria-sort] .arw{opacity:1;color:var(--accent)}
.num{text-align:right}th.num button{justify-content:flex-end}
td{border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent)}
tbody tr:hover{background:color-mix(in srgb,var(--accent) 6%,transparent)}
td b{font-weight:600}
.pill{display:inline-block;padding:2px 8px;border-radius:99px;font-size:11.5px;font-weight:600}
.pill.active{background:#dff6ec;color:#0a6e4d}.pill.trial{background:#e6eefe;color:#1450e0}.pill.churned{background:#fde8e8;color:#b42318}
:root[data-stage=dark] .pill.active{background:#0f3b2c;color:#6ee7b7}:root[data-stage=dark] .pill.trial{background:#16295a;color:#9db8ff}:root[data-stage=dark] .pill.churned{background:#4a1515;color:#fca5a5}
.dt-empty td{padding:26px;text-align:center;color:var(--muted);white-space:normal}
.dt-pager{display:flex;align-items:center;justify-content:flex-end;gap:10px;padding:8px 12px;color:var(--muted);font-size:12px}
.dt-pager button{width:28px;height:28px;border:1px solid var(--line);border-radius:8px;background:none;color:var(--ink);font-size:16px;line-height:1;cursor:pointer}
.dt-pager button:disabled{opacity:.35;cursor:default}
.dt-pager button:not(:disabled):hover,.dt-pager button:focus-visible{border-color:var(--accent);outline:none}
/*
* MIT License
*
* Copyright (c) 2026 OwnlyPlans
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
const cols=[{k:'name',t:'Customer'},{k:'plan',t:'Plan'},{k:'mrr',t:'MRR',num:1},{k:'status',t:'Status'},{k:'joined',t:'Joined',date:1}];
const rows=[['Northwind','Team',490,'active','2026-03-14'],['Acme Labs','Pro',1290,'active','2025-11-02'],['Bluebird','Starter',49,'trial','2026-10-28'],['Kestrel AI','Pro',1290,'active','2026-01-19'],
['Fieldnote','Team',490,'churned','2025-08-07'],['Lumen Health','Enterprise',4200,'active','2025-06-30'],['Parcel & Co','Starter',49,'active','2026-05-22'],['Orbit Studio','Team',0,'trial','2026-11-01'],
['Quarry','Pro',1290,'churned','2026-02-11'],['Tidepool','Starter',49,'active','2026-07-09'],['Vantage','Enterprise',3800,'active','2025-09-15'],['Wren Books','Team',490,'trial','2026-10-03']]
.map(r=>({name:r[0],plan:r[1],mrr:r[2],status:r[3],joined:r[4]}));
const PER=4,$=id=>document.getElementById(id);let sort={k:'mrr',dir:-1},q='',page=0;
const esc=s=>String(s).replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c]));
const money=n=>'$'+n.toLocaleString('en-US'),day=s=>new Date(s+'T00:00:00').toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'});
function head(){$('dt-head').innerHTML=cols.map(c=>{const on=sort.k===c.k;return '<th class="'+(c.num?'num':'')+'"'+(on?' aria-sort="'+(sort.dir>0?'ascending':'descending')+'"':'')+'><button type="button" data-k="'+c.k+'">'+c.t+'<span class="arw" aria-hidden="true">'+(on?(sort.dir>0?'\u25B2':'\u25BC'):'\u2195')+'</span></button></th>'}).join('')}
function render(){const s=q.trim().toLowerCase();
const list=rows.filter(r=>!s||Object.values(r).some(v=>String(v).toLowerCase().includes(s)))
.sort((a,b)=>{const x=a[sort.k],y=b[sort.k];return(typeof x==='number'?x-y:String(x).localeCompare(String(y)))*sort.dir});
const pages=Math.max(1,Math.ceil(list.length/PER));page=Math.min(page,pages-1);const from=page*PER,shown=list.slice(from,from+PER);
$('dt-body').innerHTML=shown.length?shown.map(r=>'<tr><td><b>'+esc(r.name)+'</b></td><td>'+esc(r.plan)+'</td><td class="num">'+money(r.mrr)+'</td><td><span class="pill '+r.status+'">'+r.status[0].toUpperCase()+r.status.slice(1)+'</span></td><td>'+day(r.joined)+'</td></tr>').join('')
:'<tr class="dt-empty"><td colspan="5">No customers match \u201c'+esc(q.trim())+'\u201d</td></tr>';
$('dt-count').textContent=list.length+' of '+rows.length;
$('dt-page').textContent=list.length?(from+1)+'\u2013'+(from+shown.length)+' of '+list.length:'0 of 0';
$('dt-prev').disabled=page===0;$('dt-next').disabled=page>=pages-1;head()}
$('dt-head').addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;const k=b.dataset.k;
sort=sort.k===k?{k,dir:-sort.dir}:{k,dir:cols.find(c=>c.k===k).num?-1:1};page=0;render();$('dt-head').querySelector('[data-k="'+k+'"]').focus()});
$('dt-q').addEventListener('input',e=>{q=e.target.value;page=0;render()});
$('dt-prev').onclick=()=>{page--;render()};$('dt-next').onclick=()=>{page++;render()};
render();
/*
* MIT License
*
* Copyright (c) 2026 OwnlyPlans
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
"use client";
import { useMemo, useState, type ReactNode } from "react";
export type Column<T> = {
key: keyof T & string;
label: string;
numeric?: boolean;
/** Custom cell. Sorting still uses the raw value. */
render?: (row: T) => ReactNode;
};
const css = `
.dt{width:100%;background:#fff;border:1px solid #d8deeb;border-radius:14px;overflow:hidden;font:13px/1.5 system-ui,sans-serif;color:#0b1020}
.dt-bar{display:flex;align-items:center;gap:10px;padding:10px 12px;border-bottom:1px solid #d8deeb}
.dt-search{flex:1;display:flex;align-items:center;gap:8px;padding:6px 10px;border:1px solid #d8deeb;border-radius:9px}
.dt-search:focus-within{border-color:#1659f5;box-shadow:0 0 0 3px rgba(22,89,245,.2)}
.dt-search input{flex:1;min-width:0;border:0;background:none;color:inherit;font:inherit;outline:none}
.dt-count{color:#5c627c;font-size:12px;white-space:nowrap}
.dt-scroll{overflow-x:auto}
.dt table{width:100%;border-collapse:collapse;min-width:440px}
.dt th,.dt td{padding:8px 12px;text-align:left;white-space:nowrap}
.dt th{padding:0;border-bottom:1px solid #d8deeb;font-size:11.5px;font-weight:600;color:#5c627c}
.dt th button{display:flex;align-items:center;gap:4px;width:100%;padding:8px 12px;border:0;background:none;color:inherit;font:inherit;text-transform:uppercase;letter-spacing:.04em;cursor:pointer}
.dt th button:hover,.dt th button:focus-visible{color:#0b1020;outline:none}
.dt th button:focus-visible{box-shadow:inset 0 0 0 2px #1659f5}
.dt th[aria-sort] button{color:#0b1020}
.dt .arw{opacity:.35;font-size:10px}.dt th[aria-sort] .arw{opacity:1;color:#1659f5}
.dt .num{text-align:right}.dt th.num button{justify-content:flex-end}
.dt td{border-bottom:1px solid #eef1f7}
.dt tbody tr:hover{background:rgba(22,89,245,.05)}
.dt-empty td{padding:26px;text-align:center;color:#5c627c}
.dt-pager{display:flex;align-items:center;justify-content:flex-end;gap:10px;padding:8px 12px;color:#5c627c;font-size:12px}
.dt-pager button{width:28px;height:28px;border:1px solid #d8deeb;border-radius:8px;background:none;font-size:16px;cursor:pointer}
.dt-pager button:disabled{opacity:.35;cursor:default}`;
export function DataTable<T extends Record<string, unknown>>({
rows,
columns,
pageSize = 10,
initialSort,
searchLabel = "Search",
}: {
rows: T[];
columns: Column<T>[];
pageSize?: number;
initialSort?: { key: keyof T & string; dir: 1 | -1 };
searchLabel?: string;
}) {
const [sort, setSort] = useState(initialSort ?? { key: columns[0].key, dir: 1 as 1 | -1 });
const [query, setQuery] = useState("");
const [page, setPage] = useState(0);
const list = useMemo(() => {
const s = query.trim().toLowerCase();
return rows
.filter((r) => !s || Object.values(r).some((v) => String(v ?? "").toLowerCase().includes(s)))
.sort((a, b) => {
const x = a[sort.key], y = b[sort.key];
const c = typeof x === "number" && typeof y === "number" ? x - y : String(x ?? "").localeCompare(String(y ?? ""), undefined, { numeric: true });
return c * sort.dir;
});
}, [rows, query, sort]);
const pages = Math.max(1, Math.ceil(list.length / pageSize));
const current = Math.min(page, pages - 1);
const from = current * pageSize;
const shown = list.slice(from, from + pageSize);
const toggle = (c: Column<T>) => {
setSort((s) => (s.key === c.key ? { key: c.key, dir: (s.dir * -1) as 1 | -1 } : { key: c.key, dir: c.numeric ? -1 : 1 }));
setPage(0);
};
return (
<div className="dt">
<style>{css}</style>
<div className="dt-bar">
<label className="dt-search">
<input type="search" placeholder={searchLabel} aria-label={searchLabel} value={query} onChange={(e) => { setQuery(e.target.value); setPage(0); }} />
</label>
<span className="dt-count" aria-live="polite">{list.length} of {rows.length}</span>
</div>
<div className="dt-scroll">
<table>
<thead>
<tr>
{columns.map((c) => {
const on = sort.key === c.key;
return (
<th key={c.key} className={c.numeric ? "num" : undefined} aria-sort={on ? (sort.dir > 0 ? "ascending" : "descending") : undefined}>
<button type="button" onClick={() => toggle(c)}>
{c.label}<span className="arw" aria-hidden="true">{on ? (sort.dir > 0 ? "▲" : "▼") : "↕"}</span>
</button>
</th>
);
})}
</tr>
</thead>
<tbody>
{shown.length ? shown.map((r, i) => (
<tr key={from + i}>
{columns.map((c) => <td key={c.key} className={c.numeric ? "num" : undefined}>{c.render ? c.render(r) : String(r[c.key] ?? "")}</td>)}
</tr>
)) : (
<tr className="dt-empty"><td colSpan={columns.length}>No rows match “{query.trim()}â€</td></tr>
)}
</tbody>
</table>
</div>
<div className="dt-pager">
<button type="button" aria-label="Previous page" disabled={current === 0} onClick={() => setPage(current - 1)}>‹</button>
<span>{list.length ? `${from + 1}–${from + shown.length} of ${list.length}` : "0 of 0"}</span>
<button type="button" aria-label="Next page" disabled={current >= pages - 1} onClick={() => setPage(current + 1)}>›</button>
</div>
</div>
);
}
/* Usage:
<DataTable rows={customers} pageSize={8} initialSort={{ key: "mrr", dir: -1 }}
columns={[{ key: "name", label: "Customer" }, { key: "mrr", label: "MRR", numeric: true, render: (r) => `$${r.mrr}` }]} />
*/
Install
npx shadcn@latest add https://ownlyplans.com/r/bhaskar/sortable-data-table.json
Adds the React files to components/ownlyplans/ in a project set up with shadcn and Tailwind CSS. Or copy them from the Code tab.
About
The table almost every dashboard needs. Click a column header to sort (click again to reverse), type to search across every column, and page through results with a clear “1–4 of 12” count. Headers are real buttons with aria-sort, so keyboard and screen reader users can sort too.
Numbers sort as numbers and dates as dates. On narrow screens the table scrolls sideways inside its card instead of breaking the page. In the React version pass your own rows and column definitions, including a custom cell renderer.
MIT License
Copyright (c) 2026 OwnlyPlans
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.