A clean data table with sortable column headers, row hover, row selection with a tri-state select-all checkbox, colored status pills and simple pagination. Rows glide to their new positions with a spring when the sort changes and fade in when you page.
Preview
| Olivia Martinolivia@acme.io | Active | Pro | $1,999 | |
| Jackson Leejackson@northwind.co | Invited | Starter | $39 | |
| Isabella Nguyenisabella@globex.com | Active | Team | $299 | |
| William Kimwill@initech.dev | Suspended | Pro | $99 | |
| Sofia Davissofia@umbrella.org | Active | Enterprise | $4,250 |
Customize
7 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add data-tableWrites components/ui/data-table.tsx and components/styles/data-table.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { DataTable } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <DataTable />6 );7}Props
Accessibility
A native <table> with a <caption> (visible or visually hidden) as its accessible name and scope="col" headers. Sortable headers contain a native button and expose aria-sort (none / ascending / descending) on the <th>. Selection uses native labelled checkboxes; the header checkbox becomes indeterminate when part of the page is selected, and the selected count is announced in a polite live region. Pagination is a labelled <nav> with labelled Previous/Next buttons that disable at the ends. Under prefers-reduced-motion rows reorder and appear instantly.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import { motion, useReducedMotion } from "motion/react";5import styles from "../styles/data-table.module.css";6 7export type DataTableRow = { id: string } & Record<string, string | number>;8 9export interface DataTableColumn {10 /** Row field rendered in this column */11 key: string;12 /** Header text */13 header: string;14 /** Cell rendering: plain text, number, USD currency or a colored status pill */15 type?: "text" | "number" | "currency" | "status";16 /** Optional second field shown as a muted line under the value */17 subKey?: string;18 /** Allow sorting by this column (default true) */19 sortable?: boolean;20}21 22export interface DataTableProps {23 /** Column definitions */24 columns?: DataTableColumn[];25 /** Row data (each row needs a unique `id`) */26 rows?: DataTableRow[];27 /** Rows per page */28 pageSize?: number;29 /** Show row selection checkboxes with a select-all header checkbox */30 selectable?: boolean;31 /** Alternate row backgrounds */32 striped?: boolean;33 /** Compact row height */34 dense?: boolean;35 /** Accent color for checkboxes, sort indicator and selected rows */36 accentColor?: string;37 /** Table caption / accessible name */38 caption?: string;39 /** Show the caption visibly above the table */40 showCaption?: boolean;41 /** Called with the selected row ids */42 onSelectionChange?: (ids: string[]) => void;43 /** Called when the sort changes (direction null = unsorted) */44 onSortChange?: (key: string | null, direction: "ascending" | "descending" | null) => void;45 /** Optional className merged onto the root */46 className?: string;47 /** Optional inline styles merged onto the root */48 style?: React.CSSProperties;49}50 51const DEFAULT_COLUMNS: DataTableColumn[] = [52 { key: "name", header: "Customer", subKey: "email" },53 { key: "status", header: "Status", type: "status" },54 { key: "plan", header: "Plan" },55 { key: "amount", header: "Amount", type: "currency" },56];57 58const D: Array<[string, string, string, string, number]> = [59 ["Olivia Martin", "olivia@acme.io", "Active", "Pro", 1999],60 ["Jackson Lee", "jackson@northwind.co", "Invited", "Starter", 39],61 ["Isabella Nguyen", "isabella@globex.com", "Active", "Team", 299],62 ["William Kim", "will@initech.dev", "Suspended", "Pro", 99],63 ["Sofia Davis", "sofia@umbrella.org", "Active", "Enterprise", 4250],64 ["Lucas Moreau", "lucas@hooli.xyz", "Active", "Team", 450],65 ["Amara Singh", "amara@stark.ind", "Invited", "Pro", 189],66 ["Mateo Rossi", "mateo@wayne.co", "Active", "Starter", 29],67 ["Hana Suzuki", "hana@cyberdyne.ai", "Suspended", "Team", 720],68 ["Noah Fischer", "noah@vandelay.com", "Active", "Enterprise", 3180],69 ["Chloe Dubois", "chloe@pied.pi", "Active", "Pro", 249],70 ["Elijah Brown", "elijah@soylent.co", "Invited", "Starter", 59],71];72const DEFAULT_ROWS: DataTableRow[] = D.map(([name, email, status, plan, amount], i) => ({ id: `r${i + 1}`, name, email, status, plan, amount }));73 74const fmtCell = (v: string | number | undefined, type: DataTableColumn["type"]) => {75 if (v === undefined) return "";76 if (type === "currency" && typeof v === "number") return v.toLocaleString("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });77 if (type === "number" && typeof v === "number") return v.toLocaleString("en-US");78 return String(v);79};80 81export function DataTable({82 columns = DEFAULT_COLUMNS,83 rows = DEFAULT_ROWS,84 pageSize = 5,85 selectable = true,86 striped = false,87 dense = false,88 accentColor = "#6366f1",89 caption = "Recent customers",90 showCaption = true,91 onSelectionChange,92 onSortChange,93 className = "",94 style,95}: DataTableProps) {96 const reduce = useReducedMotion();97 const [sort, setSort] = React.useState<{ key: string; dir: "ascending" | "descending" } | null>(null);98 const [page, setPage] = React.useState(0);99 const [selected, setSelected] = React.useState<Set<string>>(() => new Set(rows[2] ? [rows[2].id] : []));100 const allRef = React.useRef<HTMLInputElement>(null);101 const size = Math.max(1, Math.floor(pageSize));102 103 const sorted = React.useMemo(() => {104 if (!sort) return rows;105 const m = sort.dir === "ascending" ? 1 : -1;106 return rows107 .map((r, i) => [r, i] as const)108 .sort(([a, ia], [b, ib]) => {109 const x = a[sort.key];110 const y = b[sort.key];111 const c = typeof x === "number" && typeof y === "number" ? x - y : String(x ?? "").localeCompare(String(y ?? ""));112 return c * m || ia - ib;113 })114 .map(([r]) => r);115 }, [rows, sort]);116 117 const pages = Math.max(1, Math.ceil(sorted.length / size));118 const current = Math.min(page, pages - 1);119 const pageRows = sorted.slice(current * size, current * size + size);120 const pageSelected = pageRows.filter((r) => selected.has(r.id)).length;121 const allOnPage = pageRows.length > 0 && pageSelected === pageRows.length;122 123 React.useEffect(() => {124 if (allRef.current) allRef.current.indeterminate = pageSelected > 0 && !allOnPage;125 }, [pageSelected, allOnPage]);126 127 const commit = (next: Set<string>) => {128 setSelected(next);129 onSelectionChange?.([...next]);130 };131 const toggleRow = (id: string) => {132 const next = new Set(selected);133 if (next.has(id)) next.delete(id);134 else next.add(id);135 commit(next);136 };137 const toggleAll = () => {138 const next = new Set(selected);139 pageRows.forEach((r) => (allOnPage ? next.delete(r.id) : next.add(r.id)));140 commit(next);141 };142 const cycleSort = (key: string) => {143 const next = sort?.key !== key ? { key, dir: "ascending" as const } : sort.dir === "ascending" ? { key, dir: "descending" as const } : null;144 setSort(next);145 onSortChange?.(next ? next.key : null, next ? next.dir : null);146 };147 148 const spring = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 520, damping: 42, mass: 0.8 };149 150 return (151 <div152 className={`${styles.root} ${className}`.trim()}153 data-dense={dense ? "" : undefined}154 data-striped={striped ? "" : undefined}155 style={{ "--mf-dt-accent": accentColor, ...style } as React.CSSProperties}156 >157 <div className={styles.scroll}>158 <table className={styles.table}>159 <caption className={showCaption ? styles.caption : styles.srOnly}>{caption}</caption>160 <thead>161 <tr>162 {selectable && (163 <th scope="col" className={styles.check}>164 <input ref={allRef} type="checkbox" className={styles.box} aria-label="Select all rows on this page" checked={allOnPage} onChange={toggleAll} />165 </th>166 )}167 {columns.map((col) => {168 const dir = sort?.key === col.key ? sort.dir : undefined;169 const right = col.type === "currency" || col.type === "number";170 return (171 <th key={col.key} scope="col" aria-sort={col.sortable === false ? undefined : dir ?? "none"} data-align={right ? "right" : undefined}>172 {col.sortable === false ? (173 col.header174 ) : (175 <button type="button" className={styles.sortBtn} onClick={() => cycleSort(col.key)} data-active={dir ? "" : undefined}>176 {col.header}177 <svg aria-hidden="true" className={styles.sortIcon} data-dir={dir} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round">178 <path className={styles.up} d="m8 10 4-4 4 4" />179 <path className={styles.down} d="m8 14 4 4 4-4" />180 </svg>181 </button>182 )}183 </th>184 );185 })}186 </tr>187 </thead>188 <tbody>189 {pageRows.map((row, i) => {190 const isSel = selected.has(row.id);191 return (192 <motion.tr193 key={row.id}194 layout="position"195 initial={reduce ? false : { opacity: 0, y: 6 }}196 animate={{ opacity: 1, y: 0 }}197 transition={{ ...spring, opacity: { duration: reduce ? 0 : 0.2, delay: reduce ? 0 : i * 0.03 } }}198 className={styles.row}199 data-state={isSel ? "selected" : undefined}200 >201 {selectable && (202 <td className={styles.check}>203 <input type="checkbox" className={styles.box} aria-label={`Select ${String(row[columns[0]?.key ?? "id"] ?? row.id)}`} checked={isSel} onChange={() => toggleRow(row.id)} />204 </td>205 )}206 {columns.map((col) => (207 <td key={col.key} data-align={col.type === "currency" || col.type === "number" ? "right" : undefined}>208 {col.type === "status" ? (209 <span className={styles.status} data-status={String(row[col.key] ?? "").toLowerCase()}>210 {fmtCell(row[col.key], col.type)}211 </span>212 ) : (213 <span className={styles.primary}>{fmtCell(row[col.key], col.type)}</span>214 )}215 {col.subKey && <span className={styles.secondary}>{fmtCell(row[col.subKey], "text")}</span>}216 </td>217 ))}218 </motion.tr>219 );220 })}221 </tbody>222 </table>223 </div>224 <div className={styles.footer}>225 <span className={styles.count} aria-live="polite">226 {selectable ? `${selected.size} of ${rows.length} row(s) selected` : `${rows.length} rows`}227 </span>228 <nav className={styles.pager} aria-label="Pagination">229 <span className={styles.pageInfo}>230 Page {current + 1} of {pages}231 </span>232 <button type="button" className={styles.pageBtn} aria-label="Previous page" disabled={current === 0} onClick={() => setPage(current - 1)}>233 <svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round">234 <path d="m15 18-6-6 6-6" />235 </svg>236 </button>237 <button type="button" className={styles.pageBtn} aria-label="Next page" disabled={current >= pages - 1} onClick={() => setPage(current + 1)}>238 <svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round">239 <path d="m9 18 6-6-6-6" />240 </svg>241 </button>242 </nav>243 </div>244 </div>245 );246}