Sortable tables
Five tables with working sort state: a header button reorders the rows for real, and the column carries aria-sort. The comparator is deterministic (numeric when both values are numbers, code point order otherwise) and stable, so equal rows keep their original order.
Installation
The styled layer is free and installs like any other ai2 component.
Run the following command
npx shadcn@latest add @ai2/table-sortableDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install lucide-reactCopy the source
components/ui/table-sortable.tsx"use client"
import * as React from "react"
import { ArrowDown, ArrowDownUp, ArrowUp, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
/* Sortable table family: 5 tables with real working sorting. Clicking the <button>
in the header cell GENUINELY reorders the rows; the <th> carries aria-sort. The
comparator is deterministic: numeric if both values are numbers, otherwise code
point order (NO localeCompare - no locale surprises), with a stable tie-break on
the original index when equal. Color comes ONLY from semantic tokens, via alpha
color-mix. */
export type StyledSize = "sm" | "md" | "lg" | "xl"
type Column = { key: string; label: React.ReactNode }
type Row = Record<string, React.ReactNode>
type TableProps = {
className?: string
size?: StyledSize
columns?: Column[]
rows?: Row[]
}
/* A sensible default data set so it renders without props. */
const defaultColumns: Column[] = [
{ key: "name", label: "Name" },
{ key: "role", label: "Role" },
{ key: "status", label: "Status" },
]
const defaultRows: Row[] = [
{ name: "Ada Lovelace", role: "Engineer", status: "Active" },
{ name: "Alan Turing", role: "Researcher", status: "Active" },
{ name: "Grace Hopper", role: "Architect", status: "Away" },
{ name: "Katherine Johnson", role: "Analyst", status: "Active" },
]
/* Hucre yogunlugu: padding + metin olcegi. */
const cell: Record<StyledSize, string> = {
sm: "px-2.5 py-1.5 text-xs",
md: "px-3 py-2 text-sm",
lg: "px-4 py-2.5 text-sm",
xl: "px-5 py-3 text-base",
}
const headCell: Record<StyledSize, string> = {
sm: "px-2.5 py-1.5 text-xs",
md: "px-3 py-2 text-xs",
lg: "px-4 py-2.5 text-sm",
xl: "px-5 py-3 text-sm",
}
const tableBase = "w-full border-collapse text-left align-middle text-foreground"
const headBase = "font-medium text-muted-foreground"
const sortBtn =
"inline-flex w-full items-center gap-1.5 rounded-sm text-left outline-none transition-colors hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 [&_svg]:size-3.5 [&_svg]:shrink-0 [&>svg]:shrink-0 [&_i]:text-xs [&_i]:leading-none [&>i]:shrink-0"
function resolve(columns?: Column[], rows?: Row[]) {
return {
cols: columns && columns.length > 0 ? columns : defaultColumns,
data: rows && rows.length > 0 ? rows : defaultRows,
}
}
/* Hucre degerinden karsilastirilabilir metin. ReactNode elemanlari bos sayilir. */
function toText(value: React.ReactNode): string {
if (typeof value === "string") return value
if (typeof value === "number") return String(value)
return ""
}
/* Deterministik karsilastirici: sayisal ya da kod-noktasi sirasi. */
function compareText(a: string, b: string): number {
const na = Number(a)
const nb = Number(b)
if (a.trim() !== "" && b.trim() !== "" && !Number.isNaN(na) && !Number.isNaN(nb)) {
return na === nb ? 0 : na < nb ? -1 : 1
}
if (a === b) return 0
return a < b ? -1 : 1
}
type Dir = "asc" | "desc"
type SortEntry = { key: string; dir: Dir }
/* Kararli siralama: esitlikte orijinal indeks korunur. */
function sortRows(data: Row[], sorts: SortEntry[]): Row[] {
if (sorts.length === 0) return data
return data
.map((row, index) => ({ row, index }))
.sort((a, b) => {
for (const s of sorts) {
const r = compareText(toText(a.row[s.key]), toText(b.row[s.key]))
if (r !== 0) return s.dir === "asc" ? r : -r
}
return a.index - b.index
})
.map((e) => e.row)
}
/* Tek kolonlu siralama durumu (asc -> desc -> asc). */
function useSingleSort() {
const [sort, setSort] = React.useState<SortEntry | null>(null)
const toggle = React.useCallback((key: string) => {
setSort((prev) =>
prev && prev.key === key
? { key, dir: prev.dir === "asc" ? "desc" : "asc" }
: { key, dir: "asc" }
)
}, [])
const sorts = React.useMemo(() => (sort ? [sort] : []), [sort])
const dirOf = React.useCallback(
(key: string): Dir | null => (sort && sort.key === key ? sort.dir : null),
[sort]
)
return { sorts, toggle, dirOf }
}
/* Cok kolonlu siralama durumu: tiklanan kolon one alinir, asc -> desc -> cikar. */
function useMultiSort() {
const [sorts, setSorts] = React.useState<SortEntry[]>([])
const toggle = React.useCallback((key: string) => {
setSorts((prev) => {
const found = prev.find((s) => s.key === key)
const rest = prev.filter((s) => s.key !== key)
if (!found) return [...rest, { key, dir: "asc" as Dir }]
if (found.dir === "asc") return [...rest, { key, dir: "desc" as Dir }]
return rest
})
}, [])
const dirOf = React.useCallback(
(key: string): Dir | null => sorts.find((s) => s.key === key)?.dir ?? null,
[sorts]
)
const rankOf = React.useCallback(
(key: string): number => sorts.findIndex((s) => s.key === key) + 1,
[sorts]
)
return { sorts, toggle, dirOf, rankOf }
}
function ariaSort(dir: Dir | null): "ascending" | "descending" | "none" {
return dir === "asc" ? "ascending" : dir === "desc" ? "descending" : "none"
}
/* Sort: sade baslik butonlari, aktif kolon vurgulanir. */
export function SortTable({ className, size = "md", columns, rows }: TableProps) {
const { cols, data } = resolve(columns, rows)
const { sorts, toggle, dirOf } = useSingleSort()
const sorted = React.useMemo(() => sortRows(data, sorts), [data, sorts])
return (
<div
data-slot="styled-table"
className={cn("w-full overflow-x-auto rounded-lg border border-border", className)}
>
<table className={tableBase}>
<thead>
<tr className="border-b border-border bg-muted">
{cols.map((col) => {
const dir = dirOf(col.key)
return (
<th
key={col.key}
scope="col"
aria-sort={ariaSort(dir)}
className={cn(headBase, headCell[size], dir && "text-foreground")}
>
<button type="button" className={sortBtn} onClick={() => toggle(col.key)}>
{col.label}
{dir === "asc" ? <ArrowUp /> : dir === "desc" ? <ArrowDown /> : null}
</button>
</th>
)
})}
</tr>
</thead>
<tbody>
{sorted.map((row, i) => (
<tr key={i} className="border-b border-border/60 last:border-b-0">
{cols.map((col) => (
<td key={col.key} className={cell[size]}>
{row[col.key]}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}
/* Arrows: her baslikta cift ok, aktif yon dolu okla belirtilir. */
export function ArrowsTable({ className, size = "md", columns, rows }: TableProps) {
const { cols, data } = resolve(columns, rows)
const { sorts, toggle, dirOf } = useSingleSort()
const sorted = React.useMemo(() => sortRows(data, sorts), [data, sorts])
return (
<div
data-slot="styled-table"
className={cn("w-full overflow-x-auto rounded-lg border border-border", className)}
>
<table className={tableBase}>
<thead>
<tr className="border-b border-border">
{cols.map((col) => {
const dir = dirOf(col.key)
return (
<th
key={col.key}
scope="col"
aria-sort={ariaSort(dir)}
className={cn(headBase, headCell[size], dir && "text-foreground")}
>
<button type="button" className={sortBtn} onClick={() => toggle(col.key)}>
{col.label}
<span className="ml-auto inline-flex flex-col leading-none">
<ChevronUp
className={cn("-mb-1", dir === "asc" ? "text-primary" : "text-muted-foreground/50")}
/>
<ChevronDown
className={cn(dir === "desc" ? "text-primary" : "text-muted-foreground/50")}
/>
</span>
</button>
</th>
)
})}
</tr>
</thead>
<tbody>
{sorted.map((row, i) => (
<tr key={i} className="border-b border-border/60 last:border-b-0">
{cols.map((col) => (
<td key={col.key} className={cell[size]}>
{row[col.key]}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}
/* Indicator: aktif kolon primary tinti + ustte gosterge cizgisi alir. */
export function IndicatorTable({ className, size = "md", columns, rows }: TableProps) {
const { cols, data } = resolve(columns, rows)
const { sorts, toggle, dirOf } = useSingleSort()
const sorted = React.useMemo(() => sortRows(data, sorts), [data, sorts])
const activeKey = sorts[0]?.key ?? null
return (
<div
data-slot="styled-table"
className={cn("w-full overflow-x-auto rounded-lg border border-border", className)}
>
<table className={tableBase}>
<thead>
<tr className="border-b border-border bg-muted">
{cols.map((col) => {
const dir = dirOf(col.key)
return (
<th
key={col.key}
scope="col"
aria-sort={ariaSort(dir)}
className={cn(
headBase,
headCell[size],
"relative",
dir &&
"text-primary before:absolute before:inset-x-0 before:top-0 before:h-0.5 before:bg-primary"
)}
>
<button type="button" className={sortBtn} onClick={() => toggle(col.key)}>
{col.label}
{dir === "asc" ? <ArrowUp /> : dir === "desc" ? <ArrowDown /> : <ArrowDownUp className="opacity-40" />}
</button>
</th>
)
})}
</tr>
</thead>
<tbody>
{sorted.map((row, i) => (
<tr key={i} className="border-b border-border/60 last:border-b-0">
{cols.map((col) => (
<td
key={col.key}
className={cn(
cell[size],
col.key === activeKey &&
"bg-[color-mix(in_oklab,var(--color-primary)_8%,transparent)] font-medium"
)}
>
{row[col.key]}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}
/* Multi: birden fazla kolonda siralama, rozet ile oncelik sirasi gosterilir. */
export function MultiTable({ className, size = "md", columns, rows }: TableProps) {
const { cols, data } = resolve(columns, rows)
const { sorts, toggle, dirOf, rankOf } = useMultiSort()
const sorted = React.useMemo(() => sortRows(data, sorts), [data, sorts])
return (
<div
data-slot="styled-table"
className={cn("w-full overflow-x-auto rounded-lg border border-border", className)}
>
<table className={tableBase}>
<thead>
<tr className="border-b border-border bg-muted">
{cols.map((col) => {
const dir = dirOf(col.key)
const rank = rankOf(col.key)
return (
<th
key={col.key}
scope="col"
aria-sort={ariaSort(dir)}
className={cn(headBase, headCell[size], dir && "text-foreground")}
>
<button type="button" className={sortBtn} onClick={() => toggle(col.key)}>
{col.label}
{dir === "asc" ? <ArrowUp /> : dir === "desc" ? <ArrowDown /> : null}
{rank > 0 ? (
<span className="ml-auto inline-flex size-4 items-center justify-center rounded-full bg-primary text-[10px] leading-none font-semibold text-primary-foreground">
{rank}
</span>
) : null}
</button>
</th>
)
})}
</tr>
</thead>
<tbody>
{sorted.map((row, i) => (
<tr key={i} className="border-b border-border/60 last:border-b-0">
{cols.map((col) => (
<td key={col.key} className={cell[size]}>
{row[col.key]}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}
/* Sticky: kaydirilan govdenin ustunde yapiskan, siralanabilir baslik. */
export function StickyTable({ className, size = "md", columns, rows }: TableProps) {
const cols = columns && columns.length > 0 ? columns : defaultColumns
const { sorts, toggle, dirOf } = useSingleSort()
/* A longer list by default so the sticky header is actually visible. */
const base = React.useMemo(
() =>
rows && rows.length > 0
? rows
: defaultRows.concat(
defaultRows.map((r) => ({ ...r, name: `${String(r.name)} II` })),
defaultRows.map((r) => ({ ...r, name: `${String(r.name)} III` }))
),
[rows]
)
const sorted = React.useMemo(() => sortRows(base, sorts), [base, sorts])
return (
<div
data-slot="styled-table"
className={cn(
"max-h-64 w-full overflow-auto rounded-lg border border-border",
className
)}
>
<table className={tableBase}>
<thead className="sticky top-0 z-10">
<tr className="bg-muted">
{cols.map((col) => {
const dir = dirOf(col.key)
return (
<th
key={col.key}
scope="col"
aria-sort={ariaSort(dir)}
className={cn(
headBase,
headCell[size],
"border-b border-border bg-muted",
dir && "text-foreground"
)}
>
<button type="button" className={sortBtn} onClick={() => toggle(col.key)}>
{col.label}
{dir === "asc" ? <ArrowUp /> : dir === "desc" ? <ArrowDown /> : null}
</button>
</th>
)
})}
</tr>
</thead>
<tbody>
{sorted.map((row, i) => (
<tr key={i} className="border-b border-border/60 last:border-b-0">
{cols.map((col) => (
<td key={col.key} className={cell[size]}>
{row[col.key]}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}Manual installs skip the @ai2/tokens theme, so add the token CSS from the theming guide or the tone colors will be missing.
Variations
5 takes on the same idea. Each is its own export, and every one accepts a size prop (sm, md, lg, xl) aligned to the base Button scale.
Sort
Plain header buttons; the active column is highlighted.
| Ada Lovelace | Engineer | Active |
| Alan Turing | Researcher | Active |
| Grace Hopper | Architect | Away |
| Katherine Johnson | Analyst | Active |
import { SortTable } from "@/components/ui/table-sortable"
<SortTable />| Ada Lovelace | Engineer | Active |
| Alan Turing | Researcher | Active |
| Grace Hopper | Architect | Away |
| Katherine Johnson | Analyst | Active |
| Ada Lovelace | Engineer | Active |
| Alan Turing | Researcher | Active |
| Grace Hopper | Architect | Away |
| Katherine Johnson | Analyst | Active |
| Ada Lovelace | Engineer | Active |
| Alan Turing | Researcher | Active |
| Grace Hopper | Architect | Away |
| Katherine Johnson | Analyst | Active |
| Ada Lovelace | Engineer | Active |
| Alan Turing | Researcher | Active |
| Grace Hopper | Architect | Away |
| Katherine Johnson | Analyst | Active |
Arrows
A chevron pair per header marks the active direction.
| Ada Lovelace | Engineer | Active |
| Alan Turing | Researcher | Active |
| Grace Hopper | Architect | Away |
| Katherine Johnson | Analyst | Active |
import { ArrowsTable } from "@/components/ui/table-sortable"
<ArrowsTable />| Ada Lovelace | Engineer | Active |
| Alan Turing | Researcher | Active |
| Grace Hopper | Architect | Away |
| Katherine Johnson | Analyst | Active |
| Ada Lovelace | Engineer | Active |
| Alan Turing | Researcher | Active |
| Grace Hopper | Architect | Away |
| Katherine Johnson | Analyst | Active |
| Ada Lovelace | Engineer | Active |
| Alan Turing | Researcher | Active |
| Grace Hopper | Architect | Away |
| Katherine Johnson | Analyst | Active |
| Ada Lovelace | Engineer | Active |
| Alan Turing | Researcher | Active |
| Grace Hopper | Architect | Away |
| Katherine Johnson | Analyst | Active |
Indicator
The sorted column gets a primary tint and a top rule.
| Ada Lovelace | Engineer | Active |
| Alan Turing | Researcher | Active |
| Grace Hopper | Architect | Away |
| Katherine Johnson | Analyst | Active |
import { IndicatorTable } from "@/components/ui/table-sortable"
<IndicatorTable />| Ada Lovelace | Engineer | Active |
| Alan Turing | Researcher | Active |
| Grace Hopper | Architect | Away |
| Katherine Johnson | Analyst | Active |
| Ada Lovelace | Engineer | Active |
| Alan Turing | Researcher | Active |
| Grace Hopper | Architect | Away |
| Katherine Johnson | Analyst | Active |
| Ada Lovelace | Engineer | Active |
| Alan Turing | Researcher | Active |
| Grace Hopper | Architect | Away |
| Katherine Johnson | Analyst | Active |
| Ada Lovelace | Engineer | Active |
| Alan Turing | Researcher | Active |
| Grace Hopper | Architect | Away |
| Katherine Johnson | Analyst | Active |
Multi
Sort by several columns; a badge shows the priority order.
| Ada Lovelace | Engineer | Active |
| Alan Turing | Researcher | Active |
| Grace Hopper | Architect | Away |
| Katherine Johnson | Analyst | Active |
import { MultiTable } from "@/components/ui/table-sortable"
<MultiTable />| Ada Lovelace | Engineer | Active |
| Alan Turing | Researcher | Active |
| Grace Hopper | Architect | Away |
| Katherine Johnson | Analyst | Active |
| Ada Lovelace | Engineer | Active |
| Alan Turing | Researcher | Active |
| Grace Hopper | Architect | Away |
| Katherine Johnson | Analyst | Active |
| Ada Lovelace | Engineer | Active |
| Alan Turing | Researcher | Active |
| Grace Hopper | Architect | Away |
| Katherine Johnson | Analyst | Active |
| Ada Lovelace | Engineer | Active |
| Alan Turing | Researcher | Active |
| Grace Hopper | Architect | Away |
| Katherine Johnson | Analyst | Active |
Sticky
Sortable headers that stay pinned above a scrolling body.
| Ada Lovelace | Engineer | Active |
| Alan Turing | Researcher | Active |
| Grace Hopper | Architect | Away |
| Katherine Johnson | Analyst | Active |
| Ada Lovelace II | Engineer | Active |
| Alan Turing II | Researcher | Active |
| Grace Hopper II | Architect | Away |
| Katherine Johnson II | Analyst | Active |
| Ada Lovelace III | Engineer | Active |
| Alan Turing III | Researcher | Active |
| Grace Hopper III | Architect | Away |
| Katherine Johnson III | Analyst | Active |
import { StickyTable } from "@/components/ui/table-sortable"
<StickyTable />| Ada Lovelace | Engineer | Active |
| Alan Turing | Researcher | Active |
| Grace Hopper | Architect | Away |
| Katherine Johnson | Analyst | Active |
| Ada Lovelace II | Engineer | Active |
| Alan Turing II | Researcher | Active |
| Grace Hopper II | Architect | Away |
| Katherine Johnson II | Analyst | Active |
| Ada Lovelace III | Engineer | Active |
| Alan Turing III | Researcher | Active |
| Grace Hopper III | Architect | Away |
| Katherine Johnson III | Analyst | Active |
| Ada Lovelace | Engineer | Active |
| Alan Turing | Researcher | Active |
| Grace Hopper | Architect | Away |
| Katherine Johnson | Analyst | Active |
| Ada Lovelace II | Engineer | Active |
| Alan Turing II | Researcher | Active |
| Grace Hopper II | Architect | Away |
| Katherine Johnson II | Analyst | Active |
| Ada Lovelace III | Engineer | Active |
| Alan Turing III | Researcher | Active |
| Grace Hopper III | Architect | Away |
| Katherine Johnson III | Analyst | Active |
| Ada Lovelace | Engineer | Active |
| Alan Turing | Researcher | Active |
| Grace Hopper | Architect | Away |
| Katherine Johnson | Analyst | Active |
| Ada Lovelace II | Engineer | Active |
| Alan Turing II | Researcher | Active |
| Grace Hopper II | Architect | Away |
| Katherine Johnson II | Analyst | Active |
| Ada Lovelace III | Engineer | Active |
| Alan Turing III | Researcher | Active |
| Grace Hopper III | Architect | Away |
| Katherine Johnson III | Analyst | Active |
| Ada Lovelace | Engineer | Active |
| Alan Turing | Researcher | Active |
| Grace Hopper | Architect | Away |
| Katherine Johnson | Analyst | Active |
| Ada Lovelace II | Engineer | Active |
| Alan Turing II | Researcher | Active |
| Grace Hopper II | Architect | Away |
| Katherine Johnson II | Analyst | Active |
| Ada Lovelace III | Engineer | Active |
| Alan Turing III | Researcher | Active |
| Grace Hopper III | Architect | Away |
| Katherine Johnson III | Analyst | Active |
ai2 Sortable tables: 5 styled variations on the token system
The ai2 Sortable tables are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around data tables with real, working column sorting. They are free and MIT licensed, and every color comes from a semantic token, so they theme with the rest of ai2 in light and dark.
Motion runs on framer-motion: the header and column states run on token CSS transitions. The styled layer is opt-in, so the dependency only lands if you use it; the base components stay lean. Under reduced motion, the transitions are disabled and the sorted rows swap instantly.
What is in the ai2 Sortable tables?
5 exports in one file: Sort, Arrows, Indicator, Multi and Sticky. Each renders a native button and takes a size prop (sm, md, lg, xl) aligned to the base Button. They are separate from the base Button on purpose: the base keeps its clean variant, tone and size axes, while the styled layer carries the effects.
You own the file. Copy the one category file and you have all 5 variations, with no runtime dependency on ai2 itself.
Why use it
- On-system by construction: Every color resolves to an ai2 semantic token, so the buttons follow your theme in light and dark with no extra work.
- Effect without the sprawl: The decorations live in a dedicated styled file, so the base Button keeps its clean, predictable API.
- Accessible and honest: Each renders a real button element, keeps a visible focus ring, and respects prefers-reduced-motion.
Features
- Token-driven color: No hardcoded hex or oklch; the look recolors with your theme tokens.
- framer-motion: the header and column states run on token CSS transitions.
- Reduced-motion aware: Under prefers-reduced-motion, the transitions are disabled and the sorted rows swap instantly.
- Size aligned to the base: Every variation takes sm, md, lg and xl matching the base Button height scale, so styled and base buttons line up in a row.
Production tips
- Use it for emphasis, not everywhere: Styled buttons draw the eye. Reserve them for the one action you want people to take on a screen, and use the base Button for the rest.
- Keep labels as verbs: The decoration adds weight, so a clear action label keeps the button scannable.
- Pick one variation per surface: The variations share a family; using two different ones in the same view competes for attention.
Works with the rest of ai2
The Sortable tables sit alongside the base Button and the rest of the @ai2 registry. They share the same token file, so a styled action next to a base button or a badge stays visually consistent in both modes.