Tables & Data Grid
Two tiers of tabular UI: a Basic Table page showing nine hand-styled HTML-table variants built from semantic tokens + the shared UI primitives, and an AG Grid Community data grid demoed across six variant routes (default / cell renderers / row selection / search & filters / editable / pinned & totals).
Overview
The template ships two tabular building blocks so you can pick the lightest tool for the job:
| Page | Route(s) | Source | When to use |
|---|---|---|---|
| Basic Table | /tables/basic | src/pages/tables/BasicTablePage.tsx | Small, read-mostly datasets. Plain HTML <table>s styled with Tailwind tokens, presented in Panels. No grid lib. |
| Data Grid | /tables/data-grid/* | src/pages/tables/DataGridVariants.tsx + src/components/datagrid/grids.tsx | Larger, interactive datasets that need sorting, filters, selection, editing, pinning, pagination. Built on AG Grid Community. |
Both live under the Data → Tables menu section, where the six grid variants form a nested
Data Grid subgroup (src/data/menu.ts). All six grid routes are code-split, so AG Grid lands in
its own chunk and never weighs down the initial app load.
(Routes are code-split per page by default, and AG Grid additionally goes through
@/platform/clientOnly because it touches window at module scope.)
The six Data Grid variants
Each variant is its own route (and menu leaf), sharing one dataset (ROWS, 12 seeded users)
and one page shell:
| Route | Component (grids.tsx) | What it demos |
|---|---|---|
/tables/data-grid/default | DefaultGrid | Baseline grid: 7 columns, sort/filter/resize on every column, pagination (10 rows, [10, 20, 50] selector), i18n + currency valueFormatters. |
/tables/data-grid/renderers | RenderersGrid | Custom JSX cell renderers: avatar + name/email cell, Badge status pill, icon action buttons; rowHeight={64} for the taller cells. |
/tables/data-grid/selection | SelectionGrid | Multi-row selection (rowSelection={{mode: 'multiRow'}}), a live selected-count readout, and CSV export via api.exportDataAsCsv(). |
/tables/data-grid/search | SearchGrid | A quick-search Input bound to quickFilterText, plus floating filters (floatingFilter: true) under every header. |
/tables/data-grid/editable | EditableGrid | Inline editing: editable: true columns with agSelectCellEditor (role/status) and agNumberCellEditor (spend), on a copied row set. |
/tables/data-grid/pinned | PinnedGrid | Pinned columns (pinned: 'left' ID, pinned: 'right' actions) + a pinned bottom total row (pinnedBottomRowData). |
Everything used is community-safe — CSV export, quick filter, floating filters, the built-in
agSelectCellEditor/agNumberCellEditor, row pinning, and column pinning are all free
features.
The nine Basic Table variants
BasicTablePage presents each variant in its own Panel (with bodyClassName="p-0 overflow-hidden") inside a Stagger/StaggerItem cascade: default, striped,
bordered, compact, rich (avatar + email + per-row Dropdown action menu),
super-rich (presence Avatar, role pill, Progress usage bar, spend + TrendPill, star
rating, action buttons), selectable (row Checkboxes + select-all + a selection-count
bar, via the shared toggleSet helper), sortable (clickable name/spend headers, asc/desc),
and paginated (client-side slice + the Pagination component and a "showing X–Y of Z"
readout).
The AG Grid Community-only licensing rule
This is a ThemeForest template, so licensing must stay clean for every buyer:
- Only
ag-grid-community+ag-grid-react(v36, MIT-licensed / free) are used. - Never install
ag-grid-enterprise, and never enable enterprise-only features (row grouping panel, pivoting, aggregation, master/detail, server-side row model, Excel export, the integrated charts, set filters, etc.). - Registration uses
AllCommunityModule— the community module bundle. Do not swap this forAllEnterpriseModuleor add enterprise modules to the registry.
If a buyer needs enterprise features they must purchase their own AG Grid Enterprise license and wire it up themselves; the template must not ship it.
Architecture & files
| Path | Responsibility |
|---|---|
src/components/datagrid/grids.tsx | The whole grid feature: AllCommunityModule registration, the shared Row dataset, cell renderers, useGridTheme() + useRowStagger() hooks, and the six exported grid components. |
src/pages/tables/DataGridVariants.tsx | Six thin route pages (DataGridDefaultPage … DataGridPinnedPage) around a shared GridPage shell (PageHeader + icon-chip heading + Card frame). |
src/pages/tables/BasicTablePage.tsx | The nine plain-HTML table variants in Panels, using Badge/Avatar/Progress/Dropdown/Checkbox/Pagination + TrendPill. |
src/styles/_datagrid.scss | The .ag-rows-in .ag-row keyframe + :nth-child stagger delays for the grid entrance (imported via src/styles/main.scss). |
src/hooks/useChartTokens.ts | Reads the raw --* CSS tokens off <html> so the grid theme re-skins on theme/skin/OLED change (shared with charts). |
| The route entries | All six data-grid/* routes import from the same DataGridVariants module, so they share one chunk. |
src/data/menu.ts | The Data → Tables section: the nested Data Grid group (6 leaves) + Basic Table. |
The AG Grid CSS lives in the SCSS layer (_datagrid.scss), consistent with the project's
"SCSS references raw --* tokens" rule. All user-facing grid text (column headers, statuses,
roles, actions, "Total", the search placeholder) is i18n'd via the demo namespace; page
titles use nav: keys.
Usage
Basic Table
A Basic Table is just JSX in a Panel — semantic-token classes on a plain <table>, a
Badge for the status cell:
import {Badge, Panel} from '@/components/ui'
import {cn} from '@/lib/cn'
const tone = {active: 'success', pending: 'warning', suspended: 'danger'} as const
const HEAD = 'border-b border-border text-left text-xs uppercase tracking-wider text-muted-foreground'
<Panel bodyClassName="p-0 overflow-hidden" title="Team" subtitle="Read-mostly demo data">
<div className="overflow-x-auto">
<table className="w-full border-collapse text-sm">
<thead>
<tr className={HEAD}>
<th className="px-4 py-3 font-semibold">Name</th>
<th className="px-4 py-3 font-semibold">Status</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id} className="border-b border-border transition-colors last:border-0 hover:bg-surface-muted">
<td className="px-4 py-3 text-foreground">{r.name}</td>
<td className="px-4 py-3"><Badge tone={tone[r.status]}>{r.status}</Badge></td>
</tr>
))}
</tbody>
</table>
</div>
</Panel>Put the table in an overflow-x-auto div so it scrolls horizontally on narrow screens instead
of breaking the layout, and zero out the Panel body padding (bodyClassName="p-0 overflow-hidden") so rows run edge-to-edge. All colors come from semantic tokens
(border-border, text-foreground, text-muted-foreground, hover:bg-surface-muted). The
page-level entrance is Stagger/StaggerItem around the Panels (reduced-motion safe via
MotionProvider).
Data Grid
Register the community module set once at module scope, define columns with ColDef, and
theme the grid from useChartTokens():
import {AgGridReact} from 'ag-grid-react'
import {AllCommunityModule, ModuleRegistry, themeQuartz, type ColDef} from 'ag-grid-community'
// AG Grid Community (free / MIT) — register the community module set once. Never
// add ag-grid-enterprise or enterprise-only features.
ModuleRegistry.registerModules([AllCommunityModule])The grid must live inside a height-bounded container — AG Grid virtualizes its rows and
needs an explicit height. The variants use a shared GRID_HEIGHT = 540:
<div style={{height: GRID_HEIGHT, width: '100%'}}>.
API / Props
Shared hooks (src/components/datagrid/grids.tsx)
| Hook | Returns | Purpose |
|---|---|---|
useGridTheme() | a memoized Theming-API theme | themeQuartz.withParams({...}) parameterized from useChartTokens(), rebuilt on [tk] so it re-skins. |
useRowStagger() | {ref, onFirstDataRendered} | The one-time entrance: attach ref to the grid wrapper, pass the callback to onFirstDataRendered. Gated by useReducedMotion(). |
AgGridReact props used across the variants
| Prop | Variant(s) | Value / purpose |
|---|---|---|
theme | all | useGridTheme() — Theming API object (see below). |
rowData | all | The shared ROWS seed (Editable uses its own copied useState array). |
columnDefs | all | ColDef<Row>[] per variant. |
defaultColDef | all | {sortable: true, filter: true, resizable: true} (Search adds floatingFilter: true). |
animateRows | all | AG Grid's built-in animation for sort / filter / column-move. |
onFirstDataRendered | all | Triggers the one-time entrance cascade (useRowStagger). |
pagination + paginationPageSize + paginationPageSizeSelector | Default | Client-side pagination: 10 rows, [10, 20, 50]. |
rowHeight | Renderers | 64 — taller rows for the avatar cells. |
rowSelection | Selection | {mode: 'multiRow'} — the v36 object API (adds the checkbox column). |
onGridReady / onSelectionChanged | Selection | Captures GridApi into a ref / updates the selected-row count state. |
quickFilterText | Search | Bound to the controlled Input value. |
pinnedBottomRowData | Pinned | A one-element Row[] carrying the spend total. |
ColDef fields used
field, headerName, flex, maxWidth, valueFormatter (currency + i18n label mapping),
cellRenderer (custom JSX components), editable, cellEditor + cellEditorParams
(agSelectCellEditor value lists, agNumberCellEditor), pinned: 'left' | 'right', and
per-column sortable: false / filter: false (the actions column). All community-safe.
Custom cell renderers
Renderers receive ICellRendererParams<Row> and return plain JSX. Two conventions from
grids.tsx worth copying:
- Fill the cell — AG Grid doesn't vertically center custom JSX, so every renderer wraps in
className="flex h-full items-center …". - Guard pinned rows — renderers/formatters on grids with
pinnedBottomRowDatacheckp.node.rowPinned(the actions renderer returnsnullon the total row; the ID/role formatters return'').
function StatusCell(p: ICellRendererParams<Row>) {
const v = p.value as Status | undefined
if (!v) return null
return (
<div className="flex h-full items-center">
<Badge tone={STATUS_TONE[v]}>{v}</Badge>
</div>
)
}GridPage shell (DataGridVariants.tsx)
Each variant page is <GridPage navKey titleKey hintKey icon> — a PageHeader, a FadeIn,
an icon-chip heading row (bg-primary/10 text-primary chip + title + hint), and a Card className="p-3" framing the grid. navKey is a nav: key; titleKey/hintKey are demo:
keys.
Configuration & customization
Theming API (token-driven, so it re-skins)
AG Grid v36 uses the Theming API (JS theme objects), not the legacy CSS theme files. The
shared useGridTheme() starts from themeQuartz and parameterizes it from useChartTokens()
so light/dark and every color skin flow through automatically:
const tk = useChartTokens()
const theme = useMemo(
() =>
themeQuartz.withParams({
accentColor: tk.primary,
backgroundColor: tk.surface,
foregroundColor: tk.foreground,
borderColor: tk.border,
headerBackgroundColor: tk.surfaceMuted,
headerTextColor: tk.mutedForeground,
rowHoverColor: tk.surfaceMuted,
oddRowBackgroundColor: tk.background,
fontFamily: 'inherit',
headerFontWeight: 600,
}),
[tk],
)Because useChartTokens re-reads the raw --* vars whenever <html>'s class (dark),
data-skin, or data-oled attributes change, the theme object is rebuilt and the grid
re-colors live — no hardcoded hex anywhere. fontFamily: 'inherit' keeps the grid on the
app's skin-aware font.
The entrance animation — and why it animates translate, not transform
Every grid variant gets the same one-time staggered fade + rise (opacity + y: 16 → 0) as
the rest of the app's entrances. But AG Grid can't use motion/react per row — it virtualizes
and recycles its own row DOM. So the entrance is done in CSS, and there is one critical detail:
AG Grid positions every row with the CSS transform property
If the entrance animation
also animated transform, it would clobber AG Grid's positioning and the rows would jump.
Instead the keyframe animates the separate translate property, which composes with
transform — the row keeps AG Grid's transform-based position and gets the entrance
offset added on top.
src/styles/_datagrid.scss:
@keyframes ag-row-in {
from {
opacity: 0;
translate: 0 16px;
}
to {
opacity: 1;
translate: 0 0;
}
}
.ag-rows-in .ag-row {
// Mirrors EASE_OUT ([0.16, 1, 0.3, 1]) from src/lib/motion.ts.
animation: ag-row-in 0.45s cubic-bezier(0.16, 1, 0.3, 1) both;
}
@for $i from 1 through 12 {
.ag-rows-in .ag-row:nth-child(#{$i}) {
animation-delay: #{($i - 1) * 0.05}s;
}
}The :nth-child loop staggers the first 12 rows by 50 ms each. The 0.45s duration + easing
mirror EASE_OUT from src/lib/motion.ts, keeping the motion consistent with the rest of the
app.
The class that switches this on is added once, on onFirstDataRendered, and removed after
~1 s so scroll-recycled rows don't replay the entrance. That logic is the shared
useRowStagger() hook in grids.tsx:
function useRowStagger() {
const reduce = useReducedMotion()
const ref = useRef<HTMLDivElement>(null)
const onFirstDataRendered = useCallback(() => {
if (reduce) return
const el = ref.current
if (!el) return
el.classList.add('ag-rows-in')
window.setTimeout(() => el.classList.remove('ag-rows-in'), 1000)
}, [reduce])
return {ref, onFirstDataRendered}
}Reduced-motion gating
The entrance is gated by useReducedMotion() from motion/react: when the user prefers
reduced motion, the callback returns early and .ag-rows-in is never added — the grid renders
instantly with no cascade. (animateRows is AG Grid's own built-in and is left on; the custom
entrance is the reduced-motion-sensitive part.)
Columns, pagination, and formatting
- Use
flexfor proportional widths andmaxWidthto cap narrow columns (e.g. the ID column). - Format values with
valueFormatter— the shared currency formatter isconst money = (p: ValueFormatterParams<Row, number>) => `$${(p.value ?? 0).toLocaleString()}`. - Persisted-value i18n pattern: raw values stay English in the data (
'Admin','Active'); display goes through render-time key maps (ROLE_KEY/STATUS_KEY→demo:tableRole*/demo:tableStatus*) invalueFormatters and renderers — the same convention the app uses for other seeded data. - Adjust pagination via
paginationPageSize/paginationPageSizeSelector. - Editing: mark columns
editable: true; constrain choices withcellEditor: 'agSelectCellEditor'+cellEditorParams: {values: [...]}, numbers withagNumberCellEditor. Give an editable grid its own copy of the row array (the demo doesuseState(() => ROWS.map((r) => ({...r})))) so inline edits never mutate data shared with other grids. - CSV export (community-safe): capture the
GridApiinonGridReady, then callapi.exportDataAsCsv()from a toolbarButton.
Adding another grid variant
Follow the pattern the six existing ones use: add a grid component to
src/components/datagrid/grids.tsx (reuse useGridTheme + useRowStagger + ROWS), a page
wrapper in DataGridVariants.tsx, a route for it, a menu leaf in
src/data/menu.ts under the Data Grid group, and the nav:/demo: i18n keys.
Examples
A themed, entrance-animated grid (the DefaultGrid pattern)
import {useCallback, useMemo, useRef} from 'react'
import {useReducedMotion} from 'motion/react'
import {AgGridReact} from 'ag-grid-react'
import {AllCommunityModule, ModuleRegistry, themeQuartz, type ColDef} from 'ag-grid-community'
import {useChartTokens} from '@/hooks/useChartTokens'
ModuleRegistry.registerModules([AllCommunityModule])
interface Row {
id: number
name: string
email: string
status: 'Active' | 'Pending' | 'Suspended'
spend: number
}
export function ExampleGrid({rows}: {rows: Row[]}) {
const tk = useChartTokens()
const reduce = useReducedMotion()
const wrapRef = useRef<HTMLDivElement>(null)
const columnDefs = useMemo<ColDef<Row>[]>(
() => [
{field: 'id', headerName: 'ID', maxWidth: 90},
{field: 'name', headerName: 'Name', flex: 1.4},
{field: 'email', headerName: 'Email', flex: 1.6},
{field: 'status', headerName: 'Status', flex: 1},
{field: 'spend', headerName: 'Spend', flex: 1, valueFormatter: (p) => `$${(p.value ?? 0).toLocaleString()}`},
],
[],
)
const theme = useMemo(
() =>
themeQuartz.withParams({
accentColor: tk.primary,
backgroundColor: tk.surface,
foregroundColor: tk.foreground,
borderColor: tk.border,
headerBackgroundColor: tk.surfaceMuted,
headerTextColor: tk.mutedForeground,
rowHoverColor: tk.surfaceMuted,
oddRowBackgroundColor: tk.background,
fontFamily: 'inherit',
headerFontWeight: 600,
}),
[tk],
)
const onFirstDataRendered = useCallback(() => {
if (reduce) return
const el = wrapRef.current
if (!el) return
el.classList.add('ag-rows-in')
window.setTimeout(() => el.classList.remove('ag-rows-in'), 1000)
}, [reduce])
return (
<div ref={wrapRef} style={{height: 540, width: '100%'}}>
<AgGridReact<Row>
theme={theme}
rowData={rows}
columnDefs={columnDefs}
defaultColDef={{sortable: true, filter: true, resizable: true}}
animateRows
onFirstDataRendered={onFirstDataRendered}
pagination
paginationPageSize={10}
paginationPageSizeSelector={[10, 20, 50]}
/>
</div>
)
}Selection + CSV export
const apiRef = useRef<GridApi<Row> | null>(null)
const [count, setCount] = useState(0)
<Button variant="outline" size="sm" onClick={() => apiRef.current?.exportDataAsCsv()}>
Export CSV
</Button>
<AgGridReact<Row>
rowSelection={{mode: 'multiRow'}}
onGridReady={(e) => (apiRef.current = e.api)}
onSelectionChanged={(e) => setCount(e.api.getSelectedRows().length)}
/* …theme, rowData, columnDefs, entrance as above */
/>Quick search + floating filters
const [q, setQ] = useState('')
<Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search…" className="w-full sm:w-72" />
<AgGridReact<Row>
quickFilterText={q}
defaultColDef={{sortable: true, filter: true, resizable: true, floatingFilter: true}}
/* … */
/>Keeping AG Grid out of the server render
AG Grid touches window when its module loads, so it can never run during a server render. All six
variant pages are therefore marked client-only: each gets a small ClientPage.tsx shim beside its
page.tsx that defers the import to the browser.
// ClientPage.tsx — the boundary has to be its own module
'use client'
import {clientOnly} from '@/platform/clientOnly'
const DataGridDefaultPage = clientOnly(() =>
import('@/pages/tables/DataGridVariants').then((m) => ({default: m.DataGridDefaultPage})),
)
export default function ClientPage() {
return <DataGridDefaultPage />
}It lives in its own module because dynamic(…, {ssr: false}) is rejected inside a Server Component.
One boundary at the top of the page is deliberately preferred over guarding each DOM access: a grid
that virtualises its rows in the browser has nothing truthful to render on a server anyway.
Note that Basic Table needs none of this — plain <table> markup server-renders fine.
Best practices
- Never add
ag-grid-enterpriseor enterprise-only features. Keep the registry onAllCommunityModule. - Keep grid pages code-split (AG Grid is a heavy dependency) — follow the existing pattern rather than importing the grid from an eagerly-loaded module.
- Theme through tokens (
useChartTokens+themeQuartz.withParams), and reuse the shareduseGridTheme()hook rather than re-declaring the params. Never hardcode hex in the grid theme, or it won't follow dark mode / skins. - Bound the grid's height (
style={{height}}) — AG Grid virtualizes and requires it. - Memoize
columnDefsand thethemeobject so the grid isn't torn down each render. - Animate
translate, nottransformfor any custom AG Grid row motion, so it composes with AG Grid's positioning. Remove the trigger class after the cascade so recycled rows don't replay it, and gate it onuseReducedMotion()—useRowStagger()packages all of this. - Custom renderers fill the cell (
flex h-full items-center) and guardrowPinnedwhen the grid has pinned rows. - Copy the row array before enabling editing so inline edits don't mutate shared seed data.
- Keep persisted/raw values English and translate at render time via key maps
(
ROLE_KEY/STATUS_KEY→demo:keys). - For a small, static dataset prefer the Basic Table — it's lighter and needs no grid lib.
Troubleshooting
| Symptom | Cause & fix |
|---|---|
| Grid area is blank / zero height | The wrapping element has no explicit height. AG Grid virtualizes rows and needs a bounded container — set style={{height}}. |
| Rows "jump" or mis-position during the entrance | Something is animating transform on .ag-row. Use the translate property instead so it composes with AG Grid's transform positioning. |
| Entrance replays while scrolling | The .ag-rows-in class wasn't removed. It must be dropped after ~1 s so virtualized/recycled rows don't re-run the keyframe. |
| Grid colors don't match the active skin / dark mode | The theme was hardcoded instead of built from useChartTokens(). Use useGridTheme() (memoized on [tk]). |
| Custom cell content sits at the top of the row | AG Grid doesn't vertically center custom JSX — wrap the renderer in flex h-full items-center. |
| Action buttons / labels appear on the total row | Renderers and formatters must check p.node.rowPinned and bail on pinned rows. |
| Inline edits change other grids' data | The editable grid shares the seed array. Give it its own copy (ROWS.map((r) => ({...r}))). |
| Console warning about modules not registered | ModuleRegistry.registerModules([AllCommunityModule]) is missing or ran too late — call it at module scope, once. |
| Build/type error importing an enterprise module | Don't. Only import from ag-grid-community / ag-grid-react. |
| Font in the grid looks off | Set fontFamily: 'inherit' in the theme params so it uses the app's skin-aware font. |
FAQ
Why AG Grid Community and not a paid grid? It's MIT-licensed (free) and ships cleanly to every ThemeForest buyer. Enterprise features are deliberately excluded — see the licensing rule above.
Can I turn on row grouping / pivot / Excel export? No — those are enterprise-only. A buyer who needs them must license AG Grid Enterprise themselves; the template won't include it. (CSV export is community — the Selection variant demos it.)
Why six separate grid routes instead of one page?
Each variant demos one capability in isolation (renderers, selection, search, editing,
pinning), which keeps every page focused and gives the sidebar's Data Grid group real depth.
They share one source module (grids.tsx), one dataset, one theme hook, and one bundle chunk —
so the split costs nothing.
Why does the grid use a JS theme object instead of a CSS theme file?
AG Grid v36 uses the Theming API (JS theme objects like themeQuartz), which lets us
parameterize colors directly from our design tokens so the grid re-skins with the rest of the
app.
Why not use motion/react on the grid rows?
AG Grid owns and recycles its row DOM (virtualization), so per-row React motion isn't possible.
The CSS-keyframe entrance on the translate property is the equivalent that plays nicely with
AG Grid's internals.
When should I use the Basic Table vs. the Data Grid? Small, mostly-read data → Basic Table (lighter, no grid lib; the selectable/sortable/paginated variants show how far plain markup gets you). Larger, interactive data needing sort/filter/selection/editing/pinning → Data Grid.
Notes for designers & content editors
- Status pills use semantic tones (
success/warning/danger), so they adapt to light/dark and every skin automatically — don't request literal colors. - Grids get a staggered fade-in on first render; it's disabled automatically for users with "reduce motion" enabled. The cadence (rows appearing ~50 ms apart) matches the app's other entrance animations.
- The grid's header weight, hover color, zebra striping, and accent all come from the theme tokens — change them centrally in the token layer, not per-page.
- Column headers, statuses, roles, and actions are translated (the
demonamespace); the people/emails in the seed are placeholder demo data — replacerowDatawith real records when wiring to the API. - Avatars in the renderer cells come from the self-hosted
public/avatars/set.
Related
Charts
shares the useChartTokens hook and the lazy-loading pattern
Design tokens & dark mode
the -- tokens the grid theme reads
Design skins
why the grid re-colors on skin change
Panel
the portlet the Basic Table variants live in
Core & feedback components
Badge, Avatar, Progress, Pagination used in the table cells
Animation & effects
Stagger/StaggerItem, FadeIn, EASEOUT, and reduced-motion
Hooks reference
useChartTokens, useReducedMotion usage
i18n
the demo/nav namespaces and the render-time key-map pattern
Architecture & routing
route registration and code splitting
Was this page helpful?
