PVR Tech Studio

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

13 min read
Updated August 13, 2026

Overview

The template ships two tabular building blocks so you can pick the lightest tool for the job:

PageRoute(s)SourceWhen to use
Basic Table/tables/basicsrc/pages/tables/BasicTablePage.tsxSmall, 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.tsxLarger, 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:

RouteComponent (grids.tsx)What it demos
/tables/data-grid/defaultDefaultGridBaseline grid: 7 columns, sort/filter/resize on every column, pagination (10 rows, [10, 20, 50] selector), i18n + currency valueFormatters.
/tables/data-grid/renderersRenderersGridCustom JSX cell renderers: avatar + name/email cell, Badge status pill, icon action buttons; rowHeight={64} for the taller cells.
/tables/data-grid/selectionSelectionGridMulti-row selection (rowSelection={{mode: 'multiRow'}}), a live selected-count readout, and CSV export via api.exportDataAsCsv().
/tables/data-grid/searchSearchGridA quick-search Input bound to quickFilterText, plus floating filters (floatingFilter: true) under every header.
/tables/data-grid/editableEditableGridInline editing: editable: true columns with agSelectCellEditor (role/status) and agNumberCellEditor (spend), on a copied row set.
/tables/data-grid/pinnedPinnedGridPinned 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 for AllEnterpriseModule or 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

PathResponsibility
src/components/datagrid/grids.tsxThe whole grid feature: AllCommunityModule registration, the shared Row dataset, cell renderers, useGridTheme() + useRowStagger() hooks, and the six exported grid components.
src/pages/tables/DataGridVariants.tsxSix thin route pages (DataGridDefaultPage … DataGridPinnedPage) around a shared GridPage shell (PageHeader + icon-chip heading + Card frame).
src/pages/tables/BasicTablePage.tsxThe nine plain-HTML table variants in Panels, using Badge/Avatar/Progress/Dropdown/Checkbox/Pagination + TrendPill.
src/styles/_datagrid.scssThe .ag-rows-in .ag-row keyframe + :nth-child stagger delays for the grid entrance (imported via src/styles/main.scss).
src/hooks/useChartTokens.tsReads the raw --* CSS tokens off <html> so the grid theme re-skins on theme/skin/OLED change (shared with charts).
The route entriesAll six data-grid/* routes import from the same DataGridVariants module, so they share one chunk.
src/data/menu.tsThe 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)

HookReturnsPurpose
useGridTheme()a memoized Theming-API themethemeQuartz.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

PropVariant(s)Value / purpose
themealluseGridTheme() — Theming API object (see below).
rowDataallThe shared ROWS seed (Editable uses its own copied useState array).
columnDefsallColDef<Row>[] per variant.
defaultColDefall{sortable: true, filter: true, resizable: true} (Search adds floatingFilter: true).
animateRowsallAG Grid's built-in animation for sort / filter / column-move.
onFirstDataRenderedallTriggers the one-time entrance cascade (useRowStagger).
pagination + paginationPageSize + paginationPageSizeSelectorDefaultClient-side pagination: 10 rows, [10, 20, 50].
rowHeightRenderers64 — taller rows for the avatar cells.
rowSelectionSelection{mode: 'multiRow'} — the v36 object API (adds the checkbox column).
onGridReady / onSelectionChangedSelectionCaptures GridApi into a ref / updates the selected-row count state.
quickFilterTextSearchBound to the controlled Input value.
pinnedBottomRowDataPinnedA 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 pinnedBottomRowData check p.node.rowPinned (the actions renderer returns null on 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:

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 flex for proportional widths and maxWidth to cap narrow columns (e.g. the ID column).
  • Format values with valueFormatter — the shared currency formatter is const 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*) in valueFormatters and renderers — the same convention the app uses for other seeded data.
  • Adjust pagination via paginationPageSize / paginationPageSizeSelector.
  • Editing: mark columns editable: true; constrain choices with cellEditor: 'agSelectCellEditor' + cellEditorParams: {values: [...]}, numbers with agNumberCellEditor. Give an editable grid its own copy of the row array (the demo does useState(() => ROWS.map((r) => ({...r})))) so inline edits never mutate data shared with other grids.
  • CSV export (community-safe): capture the GridApi in onGridReady, then call api.exportDataAsCsv() from a toolbar Button.

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-enterprise or enterprise-only features. Keep the registry on AllCommunityModule.
  • 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 shared useGridTheme() 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 columnDefs and the theme object so the grid isn't torn down each render.
  • Animate translate, not transform for 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 on useReducedMotion() — useRowStagger() packages all of this.
  • Custom renderers fill the cell (flex h-full items-center) and guard rowPinned when 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

SymptomCause & fix
Grid area is blank / zero heightThe 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 entranceSomething is animating transform on .ag-row. Use the translate property instead so it composes with AG Grid's transform positioning.
Entrance replays while scrollingThe .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 modeThe theme was hardcoded instead of built from useChartTokens(). Use useGridTheme() (memoized on [tk]).
Custom cell content sits at the top of the rowAG Grid doesn't vertically center custom JSX — wrap the renderer in flex h-full items-center.
Action buttons / labels appear on the total rowRenderers and formatters must check p.node.rowPinned and bail on pinned rows.
Inline edits change other grids' dataThe editable grid shares the seed array. Give it its own copy (ROWS.map((r) => ({...r}))).
Console warning about modules not registeredModuleRegistry.registerModules([AllCommunityModule]) is missing or ran too late — call it at module scope, once.
Build/type error importing an enterprise moduleDon't. Only import from ag-grid-community / ag-grid-react.
Font in the grid looks offSet 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 demo namespace); the people/emails in the seed are placeholder demo data — replace rowData with real records when wiring to the API.
  • Avatars in the renderer cells come from the self-hosted public/avatars/ set.

Was this page helpful?