UIData Displaymotion/react

DataTable

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

Recent customers
Olivia Martinolivia@acme.ioActivePro$1,999
Jackson Leejackson@northwind.coInvitedStarter$39
Isabella Nguyenisabella@globex.comActiveTeam$299
William Kimwill@initech.devSuspendedPro$99
Sofia Davissofia@umbrella.orgActiveEnterprise$4,250

Customize

7 props
5

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add data-table

Writes 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.

example.tsx
1import { DataTable } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <DataTable />6  );7}

Props

PropTypeDefault
columns

Column definitions: { key, header, type?: 'text' | 'number' | 'currency' | 'status', subKey?, sortable? }.

DataTableColumn[]Customer / Status / Plan / Amount
rows

Row objects with a unique id plus string/number fields referenced by the columns.

DataTableRow[]12 sample customers
pageSize

Rows per page.

number5
selectable

Show row checkboxes and a tri-state select-all (current page) checkbox.

booleantrue
striped

Alternate row backgrounds.

booleanfalse
dense

Compact row height (hides secondary lines).

booleanfalse
accentColor

Checkbox, active sort arrow and selected-row tint color.

string'#6366f1'
caption

Table caption and accessible name.

string'Recent customers'
showCaption

Show the caption visibly (otherwise it is visually hidden).

booleantrue
onSelectionChange

Called with the selected row ids whenever selection changes.

(ids: string[]) => voidundefined
onSortChange

Called when a header cycles ascending, descending and unsorted.

(key: string | null, direction: 'ascending' | 'descending' | null) => voidundefined
className

Additional class merged onto the root element.

string''
style

Inline styles merged onto the root element.

React.CSSPropertiesundefined

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

components/ui/data-table.tsx
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}